Merge "Add new 'read as' capability."
diff --git a/tools/bazel.rc b/.bazelrc
similarity index 100%
rename from tools/bazel.rc
rename to .bazelrc
diff --git a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch b/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
deleted file mode 100644
index 3ccf5cd..0000000
--- a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
+++ /dev/null
@@ -1,133 +0,0 @@
-Date: Wed, 30 May 2018 21:22:18 +0200
-Subject: [PATCH] Replace native {http,git}_archive with Skylark rules
-
-See [1] for more details.
-
-Test Plan:
-
-* Apply this CL on Bazel master: [2] and build bazel
-* Run with this custom built bazel version:
-
-  $ bazel test //javatests/...
-  $ bazel test //closure/...
-
-[1] https://groups.google.com/d/topic/bazel-discuss/dO2MHQLwJF0/discussion
-[2] https://bazel-review.googlesource.com/#/c/bazel/+/55932/
----
- closure/repositories.bzl | 23 ++++++++++++-----------
- 1 file changed, 12 insertions(+), 11 deletions(-)
-
-diff --git a/closure/repositories.bzl b/closure/repositories.bzl
-index 9b84a72..2816fb6 100644
---- closure/repositories.bzl
-+++ closure/repositories.bzl
-@@ -14,6 +14,7 @@
- 
- """External dependencies for Closure Rules."""
- 
-+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
- load("//closure/private:java_import_external.bzl", "java_import_external")
- load("//closure/private:platform_http_file.bzl", "platform_http_file")
- load("//closure:filegroup_external.bzl", "filegroup_external")
-@@ -405,7 +406,7 @@ def com_google_common_html_types():
-   )
- 
- def com_google_common_html_types_html_proto():
--  native.http_file(
-+  http_file(
-       name = "com_google_common_html_types_html_proto",
-       sha256 = "6ece202f11574e37d0c31d9cf2e9e11a0dbc9218766d50d211059ebd495b49c3",
-       urls = [
-@@ -633,7 +634,7 @@ def com_google_javascript_closure_compiler():
- 
- def com_google_javascript_closure_library():
-   # After updating: bazel run //closure/library:regenerate -- "$PWD"
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_javascript_closure_library",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/closure-library/archive/v20180405.tar.gz",
-@@ -658,7 +659,7 @@ def com_google_jsinterop_annotations():
- 
- def com_google_protobuf():
-   # Note: Protobuf 3.6.0+ is going to use C++11
--  native.http_archive(
-+  http_archive(
-       name = "com_google_protobuf",
-       strip_prefix = "protobuf-3.5.1",
-       sha256 = "826425182ee43990731217b917c5c3ea7190cfda141af4869e6d4ad9085a740f",
-@@ -669,7 +670,7 @@ def com_google_protobuf():
-   )
- 
- def com_google_protobuf_js():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_protobuf_js",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/protobuf/archive/v3.5.1.tar.gz",
-@@ -722,7 +723,7 @@ def com_google_template_soy():
-   )
- 
- def com_google_template_soy_jssrc():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_template_soy_jssrc",
-       sha256 = "c76ab4cb6e46a7c76336640b3c40d6897b420209a6c0905cdcd32533dda8126a",
-       urls = [
-@@ -757,7 +758,7 @@ def com_squareup_javapoet():
-   )
- 
- def fonts_noto_hinted_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_hinted_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-hinted_20161116-1_all.deb",
-@@ -767,7 +768,7 @@ def fonts_noto_hinted_deb():
-   )
- 
- def fonts_noto_mono_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_mono_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-mono_20161116-1_all.deb",
-@@ -801,7 +802,7 @@ def javax_inject():
-   )
- 
- def libexpat_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libexpat_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/e/expat/libexpat1_2.1.0-6+deb8u3_amd64.deb",
-@@ -811,7 +812,7 @@ def libexpat_amd64_deb():
-   )
- 
- def libfontconfig_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfontconfig_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fontconfig/libfontconfig1_2.11.0-6.3+deb8u1_amd64.deb",
-@@ -821,7 +822,7 @@ def libfontconfig_amd64_deb():
-   )
- 
- def libfreetype_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfreetype_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/freetype/libfreetype6_2.5.2-3+deb8u1_amd64.deb",
-@@ -831,7 +832,7 @@ def libfreetype_amd64_deb():
-   )
- 
- def libpng_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libpng_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/libp/libpng/libpng12-0_1.2.50-2+deb8u2_amd64.deb",
--- 
-2.16.3
-
diff --git a/BUILD b/BUILD
index 91e2dec..4bba5a5 100644
--- a/BUILD
+++ b/BUILD
@@ -3,6 +3,13 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:pkg_war.bzl", "pkg_war")
 
+config_setting(
+    name = "java9",
+    values = {
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java9",
+    },
+)
+
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 397b99a..56200c4 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -841,6 +841,17 @@
 This category permits users to delete their own changes if they are not merged
 yet. This means only own changes that are open or abandoned can be deleted.
 
+[[category_delete_changes]]
+=== Delete Changes
+
+This category permits users to delete other users' changes if they are not merged
+yet. This means only changes that are open or abandoned can be deleted.
+
+Having this permission implies having the link:#category_delete_own_changes[
+Delete Own Changes] permission.
+
+Administrators may always delete changes without having this permission.
+
 [[category_edit_topic_name]]
 === Edit Topic Name
 
diff --git a/Documentation/cmd-index-project.txt b/Documentation/cmd-index-changes-in-project.txt
similarity index 60%
rename from Documentation/cmd-index-project.txt
rename to Documentation/cmd-index-changes-in-project.txt
index 2196a26..ec1626f 100644
--- a/Documentation/cmd-index-project.txt
+++ b/Documentation/cmd-index-changes-in-project.txt
@@ -1,12 +1,12 @@
-= gerrit index project
+= gerrit index changes in project
 
 == NAME
-gerrit index project - Index all the changes in one or more projects.
+gerrit index changes in project - Index all the changes in one or more projects.
 
 == SYNOPSIS
 [verse]
 --
-_ssh_ -p <port> <host> _gerrit index project_ <PROJECT> [<PROJECT> ...]
+_ssh_ -p <port> <host> _gerrit index changes-in-project_ <PROJECT> [<PROJECT> ...]
 --
 
 == DESCRIPTION
@@ -26,7 +26,7 @@
 Index all changes in projects MyProject and NiceProject.
 
 ----
-    $ ssh -p 29418 user@review.example.com gerrit index project MyProject NiceProject
+    $ ssh -p 29418 user@review.example.com gerrit index changes-in-project MyProject NiceProject
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index f535281..496c205 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -18,12 +18,14 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[client_commands]]Commands
+[[client_commands]]
+=== Commands
 
 link:cmd-cherry-pick.html[gerrit-cherry-pick]::
 	Download and cherry-pick one or more changes (commits).
 
-=== [[client_hooks]]Hooks
+[[client_hooks]]
+=== Hooks
 
 Client hooks can be installed into a local Git repository, improving
 the developer experience when working with a Gerrit Code Review
@@ -47,7 +49,8 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[user_commands]]User Commands
+[[user_commands]]
+=== User Commands
 
 link:cmd-apropos.html[gerrit apropos]::
 	Search Gerrit documentation index.
@@ -85,6 +88,9 @@
 link:cmd-set-project.html[gerrit set-project]::
 	Change a project's settings.
 
+link:cmd-set-project-parent.html[gerrit set-project-parent]::
+	Change the project permissions are inherited from.
+
 link:cmd-set-reviewers.html[gerrit set-reviewers]::
 	Add or remove reviewers on a change.
 
@@ -103,8 +109,8 @@
 git upload-pack::
 	Standard Git server side command for client side `git fetch`.
 
-[[admin_commands]]Administrator Commands
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+[[admin_commands]]
+=== Administrator Commands
 
 link:cmd-close-connection.html[gerrit close-connection]::
 	Close the specified SSH connection.
@@ -136,7 +142,7 @@
 link:cmd-index-changes.html[gerrit index changes]::
 	Index one or more changes.
 
-link:cmd-index-project.html[gerrit index project]::
+link:cmd-index-changes-in-project.html[gerrit index changes-in-project]::
 	Index all the changes in one or more projects.
 
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
@@ -178,9 +184,6 @@
 link:cmd-set-members.html[gerrit set-members]::
 	Set group members.
 
-link:cmd-set-project-parent.html[gerrit set-project-parent]::
-	Change the project permissions are inherited from.
-
 link:cmd-show-caches.html[gerrit show-caches]::
 	Display current cache statistics.
 
@@ -205,6 +208,36 @@
 link:cmd-suexec.html[suexec]::
 	Execute a command as any registered user account.
 
+[[trace]]
+=== Trace
+
+For executing SSH commands tracing can be enabled by setting the
+`--trace` and `--trace-id <trace-id>` options. It is recommended to use
+the ID of the issue that is being investigated as trace ID.
+
+----
+  $ ssh -p 29418 review.example.com gerrit create-project --trace --trace-id issue/123 foo/bar
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+----
+  $ ssh -p 29418 review.example.com gerrit create-project --trace foo/bar
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. The trace ID is printed to
+the stderr command output:
+
+----
+  TRACE_ID: 1534174322774-7edf2a7b
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 59ed6ff..998b143 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -129,9 +129,9 @@
 -t::
   Apply a 'TAG' to the change message, votes, and inline comments. The 'TAG'
   can represent an external system like CI that does automated verification
-  of the change. Comments with specific 'TAG' values can be filtered out in
-  the web UI.
-  Note that to apply different tags on on different votes/comments, multiple
+  of the change. Comments that contain TAG values with 'autogenerated:' prefix
+  can be filtered out in the web UI.
+  Note that to apply different tags on different votes/comments, multiple
   invocations of the SSH command are required.
 
 == ACCESS
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 884c8cc..276306e 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -12,6 +12,7 @@
   [--preferred-email <EMAIL>]
   [--add-ssh-key - | <KEY>]
   [--delete-ssh-key - | <KEY> | ALL]
+  [--generate-http-password]
   [--http-password <PASSWORD>]
   [--clear-http-password] <USER>
 --
@@ -25,8 +26,9 @@
 verification step we force within the UI.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group,
-or have been granted
+Users can call this to update their own accounts. To update a different
+account, a caller must be a member of the privileged 'Administrators'
+group, or have been granted
 link:access-control.html#capability_modifyAccount[the 'Modify Account' global capability].
 For security reasons only the members of the privileged 'Administrators'
 group can add or delete SSH keys for a user.
@@ -93,6 +95,11 @@
     May be supplied more than once to delete multiple SSH
     keys in a single command execution.
 
+--generate-http-password::
+    Generate a new random HTTP password for the user account
+    similar to the web ui. The password will be output to the
+    user on success with a line: `New password: <PASSWORD>`.
+
 --http-password::
     Set the HTTP password for the user account.
 
diff --git a/Documentation/cmd-set-project-parent.txt b/Documentation/cmd-set-project-parent.txt
index 6e2328c..ec5a5c6 100644
--- a/Documentation/cmd-set-project-parent.txt
+++ b/Documentation/cmd-set-project-parent.txt
@@ -20,7 +20,11 @@
 the project to inherit through another one.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group.
+Caller must be a member of the privileged 'Administrators' group
+or, if
+link:config-gerrit.html#receive.allowProjectOwnersToChangeParent[receive.allowProjectOwnersToChangeParent]
+is enabled, be a project owner of the projects that is getting their
+parent updated.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 72a9c21..37af110 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -90,6 +90,16 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+== Change Deleted
+
+Sent when a change has been deleted.
+
+type:: "change-deleted"
+
+change:: link:json.html#change[change attribute]
+
+deleter:: link:json.html#account[account attribute]
+
 === Change Merged
 
 Sent when a change has been merged into the git repository.
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 6378632..51d8cec 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -296,6 +296,11 @@
 an external ID is used only once (e.g. an external ID can never be
 assigned to multiple accounts at a point in time).
 
+[IMPORTANT]
+If the external ID key is changed manually you must adapt the note key
+to the new SHA1. Otherwise the external ID becomes inconsistent and is
+ignored by Gerrit.
+
 The note content is a Git config file:
 
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3f97120..4e43ca7 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -616,7 +616,10 @@
 existing accounts this username is already in lower case. It is not
 possible to convert the usernames of the existing accounts to lower
 case because this would break the access to existing per-user
-branches.
+branches and Gerrit provides no tool to do such a conversion.
++
+Setting this parameter to `true` will prevent all users from login that
+have a non-lower-case username.
 +
 This parameter only affects git over http and git over SSH traffic.
 +
@@ -786,6 +789,7 @@
 +
 * `"change_notes"`: disk storage is disabled by default
 * `"diff_summary"`: default is `1g` (1 GiB of disk space)
+* `"external_ids_map"`: disk storage is disabled by default
 
 +
 If 0 or negative, disk storage for the cache is disabled.
@@ -862,8 +866,10 @@
 cache may temporarily contain 2 entries, but the second one is promptly
 expired.
 +
-It is not recommended to change the attributes of this cache away from
-the defaults.
+It is not recommended to change the in-memory attributes of this cache
+away from the defaults. The cache may be persisted by setting
+`diskLimit`, which is only recommended if cold start performance is
+problematic.
 
 cache `"git_tags"`::
 +
@@ -2226,20 +2232,6 @@
 Defaults to the full hostname of the Gerrit server.
 
 
-[[gerrit.ui]]gerrit.ui::
-+
-Default UI when the user does not request a different preference via argument
-or cookie.
-+
-* `GWT` for the old-style Google Web Toolkit-based interface.
-* `POLYGERRIT` for the new Polymer-based HTML5 Web interface.
-+
-A sanity check during startup is performed that the value of
-gerrit.ui is an enabled UI.
-+
-Defaults to GWT (if GWT is enabled) or POLYGERRIT (if POLYGERRIT is
-enabled and GWT is disabled)
-
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -2793,16 +2785,16 @@
 it to 0 disables the dedicated thread pool and indexing will be done in the same
 thread as the operation.
 +
-If not set or set to a negative value, defaults to 1 plus half of the number of
-logical CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
 online schema upgrades.
 +
-If not set or set to a negative value, defaults to the number of logical
-CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
@@ -2993,14 +2985,18 @@
 [[elasticsearch]]
 === Section elasticsearch
 
-WARNING: The Elasticsearch support has only been tested with Elasticsearch
-versions 2.4, 5.6 and 6.2. Support for other versions is not guaranteed.
+WARNING: Support for Elasticsearch is still experimental and is not recommended
+for production use. Support has been tested with Elasticsearch versions 2.4, 5.6,
+6.2 and 6.3. Support for other versions is not guaranteed.
 
-Open and closed changes are indexed in a single index, separated into types
-`open_changes` and `closed_changes` respectively, if using Elasticsearch
-versions 2.4 or 5.6. Open and closed changes are merged into the default `_doc`
-type otherwise. The latter is also used for accounts and groups indices starting
-with Elasticsearch 6.2.
+When using Elasticsearch versions 2.4 and 5.6, the open and closed changes are
+indexed in a single index, separated into types `open_changes` and `closed_changes`
+respectively. When using version 6.2 or later, the open and closed changes are
+merged into the default `_doc` type. The latter is also used for the accounts and
+groups indices starting with Elasticsearch 6.2.
+
+Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
+server(s) must be reachable during the site initialization.
 
 [[elasticsearch.prefix]]elasticsearch.prefix::
 +
@@ -3010,11 +3006,43 @@
 +
 Not set by default.
 
+[[elasticsearch.server]]elasticsearch.server::
++
+Elasticsearch server URI in the form `http[s]://hostname:port`. The `port` is
+optional and defaults to `9200` if not specified.
++
+At least one server must be specified. May be specified multiple times to
+configure multiple Elasticsearch servers.
++
+Note that the site initialization program only allows to configure a single
+server. To configure multiple servers the `gerrit.config` file must be edited
+manually.
+
+[[elasticsearch.maxRetryTimeout]]elasticsearch.maxRetryTimeout::
++
+Sets the maximum timeout to honor in case of multiple retries of the same request.
++
+The value is in the usual time-unit format like `1 m`, `5 m`.
++
+Defaults to `30000 ms`.
+
+==== Elasticsearch Security
+
+When security is enabled in Elasticsearch, the username and password must be provided.
+Note that the same username and password are used for all servers.
+
+For further information about Elasticsearch security, please refer to the documentation:
+
+* link:https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/security.html[Elasticsearch 2.4]
+* link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6]
+* link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3]
+
 [[elasticsearch.username]]elasticsearch.username::
 +
 Username used to connect to Elasticsearch.
 +
-Not set by default.
+If a password is set, defaults to `elastic`, otherwise not set by default.
 
 [[elasticsearch.password]]elasticsearch.password::
 +
@@ -3022,65 +3050,6 @@
 +
 Not set by default.
 
-[[elasticsearch.requestCompression]]elasticsearch.requestCompression::
-+
-Enable request compression.
-+
-Defaults to `false`.
-
-[[elasticsearch.connectionTimeout]]elasticsearch.connectionTimeout::
-+
-How long should Gerrit waits for connection.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-[[elasticsearch.maxConnectionIdleTime]]elasticsearch.maxConnectionIdleTime::
-+
-How long connection can stay in idle.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-[[elasticsearch.maxTotalConnection]]elasticsearch.maxTotalConnection::
-+
-How many connections can be spawned simultaneously.
-+
-Defaults to `1`
-
-[[elasticsearch.maxReadTimeout]]elasticsearch.maxReadTimeout::
-+
-Timeout for read operations.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-==== Elasticsearch server(s) configuration
-
-Each section corresponds to one Elasticsearch server.
-
-[[elasticsearch.name.protocol]]elasticsearch.name.protocol::
-+
-Elasticsearch server protocol. Can be `http` or `https`.
-+
-Defaults to `http`.
-
-[[elasticsearch.name.hostname]]elasticsearch.name.hostname::
-+
-Elasticsearch server hostname.
-
-Defaults to `localhost`.
-
-[[elasticsearch.name.port]]elasticsearch.name.port::
-+
-Elasticsearch server port.
-+
-Defaults to `9200`.
-
-
 [[ldap]]
 === Section ldap
 
@@ -3704,6 +3673,17 @@
 +
 Default is true.
 
+[[receive.allowProjectOwnersToChangeParent]]receive.allowProjectOwnersToChangeParent::
++
+If true, Gerrit will allow project owners to change the parent of a project.
++
+By default only Gerrit administrators are allowed to change the parent
+of a project. By allowing project owners to change parents, it may
+allow the owner to circumvent certain enforced rules (like important
+BLOCK rules).
++
+Default is false.
+
 [[receive.checkReferencedObjectsAreReachable]]receive.checkReferencedObjectsAreReachable::
 +
 If set to true, Gerrit will validate that all referenced objects that
@@ -3763,7 +3743,7 @@
 from pushing objects which are too large to Gerrit.
 +
 This setting can also be set in the `project.config`
-link:config-project-config.html[receive.maxObjectSizeLimit] in order
+(link:config-project-config.html[receive.maxObjectSizeLimit]) in order
 to further reduce the global setting. The project specific setting is
 only honored when it further reduces the global limit.
 +
@@ -3771,6 +3751,14 @@
 +
 Common unit suffixes of 'k', 'm', or 'g' are supported.
 
+[[receive.inheritProjectMaxObjectSizeLimit]]receive.inheritProjectMaxObjectSizeLimit::
++
+Controls whether the project-level link:config-project-config.html[`receive.maxObjectSizeLimit`]
+value is inherited from the parent project. When `true`, the value is
+inherited, otherwise it is not inherited.
++
+Default is false, the value is not inherited.
+
 [[receive.maxTrustDepth]]receive.maxTrustDepth::
 +
 If signed push validation is link:#receive.enableSignedPush[enabled],
@@ -4663,7 +4651,7 @@
 
 [[theme.backgroundColor]]theme.backgroundColor::
 +
-Background color for the page, and major data tables like the all
+_(GWT UI only)_ Background color for the page, and major data tables like the all
 open changes table or the account dashboard. The value must be a
 valid HTML hex color code, or standard color name.
 +
@@ -4671,7 +4659,7 @@
 
 [[theme.topMenuColor]]theme.topMenuColor::
 +
-This is the color of the main menu bar at the top of the page.
+_(GWT UI only)_ This is the color of the main menu bar at the top of the page.
 The value must be a valid HTML hex color code, or standard color
 name.
 +
@@ -4679,53 +4667,52 @@
 
 [[theme.textColor]]theme.textColor::
 +
-Text color for the page, and major data tables like the all
-open changes table or the account dashboard. The value must be a
-valid HTML hex color code, or standard color name.
+_(GWT UI only)_ Text color for the page, and major data tables like the all open
+changes table or the account dashboard. The value must be a valid HTML hex color
+code, or standard color name.
 +
 By default dark grey, `353535`.
 
 [[theme.trimColor]]theme.trimColor::
 +
-Primary color used as a background color behind text.  This is
-the color of the main menu bar at the top, of table headers,
-and of major UI areas that we want to offset from other portions
-of the page.  The value must be a valid HTML hex color code, or
-standard color name.
+_(GWT UI only)_ Primary color used as a background color behind text.  This is
+the color of the main menu bar at the top, of table headers, and of major UI
+areas that we want to offset from other portions of the page.  The value must be
+a valid HTML hex color code, or standard color name.
 +
 By default a light grey, `EEEEEE`.
 
 [[theme.selectionColor]]theme.selectionColor::
 +
-Background color used within a trimColor area to denote the currently
-selected tab, or the background color used in a table to denote the
-currently selected row.  The value must be a valid HTML hex color
-code, or standard color name.
+_(GWT UI only)_ Background color used within a trimColor area to denote the
+currently selected tab, or the background color used in a table to denote the
+currently selected row.  The value must be a valid HTML hex color code, or
+standard color name.
 +
 By default a pale blue, `D8EDF9`.
 
 [[theme.changeTableOutdatedColor]]theme.changeTableOutdatedColor::
 +
-Background color used for patch outdated messages.  The value must be
-a valid HTML hex color code, or standard color name.
+_(GWT UI only)_ Background color used for patch outdated messages.  The value
+must be a valid HTML hex color code, or standard color name.
 +
 By default a shade of red, `F08080`.
 
 [[theme.tableOddRowColor]]theme.tableOddRowColor::
 +
-Background color for tables such as lists of open reviews for odd
-rows.  This is so you can have a different color for odd and even
-rows of the table.  The value must be a valid HTML hex color code,
-or standard color name.
+_(GWT UI only)_ Background color for tables such as lists of open reviews for
+odd rows.  This is so you can have a different color for odd and even rows of
+the table.  The value must be a valid HTML hex color code, or standard color
+name.
 +
 By default transparent.
 
 [[theme.tableEvenRowColor]]theme.tableEvenRowColor::
 +
-Background color for tables such as lists of open reviews for even
-rows.  This is so you can have a different color for odd and even
-rows of the table.  The value must be a valid HTML hex color code,
-or standard color name.
+_(GWT UI only)_ Background color for tables such as lists of open reviews for
+even rows.  This is so you can have a different color for odd and even rows of
+the table.  The value must be a valid HTML hex color code, or standard color
+name.
 +
 By default transparent.
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index cf78c6d..ff43520 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -238,7 +238,9 @@
 The `PatchSetLock` function provides a locking mechanism for patch
 sets.  This function's values are not considered when determining
 whether a change is submittable. When set, no new patchsets can be
-created and rebase and abandon are blocked.
+created and rebase and abandon are blocked. This is useful to prevent
+updates to a change while (potentially expensive) CI
+validation is running.
 +
 This function is designed to allow overlapping locks, so several lock
 accounts could lock the same change.
@@ -368,6 +370,18 @@
 ignored if the label doesn't apply for that branch.
 Additionally, the `branch` modifier has no effect when the submit rule
 is customized in the rules.pl of the project or inherited from parent projects.
+Branch can be a ref pattern similar to what is documented
+link:access-control.html#reference[here], but must not contain `${username}` or
+`${shardeduserid}`.
+
+[[label_ignoreSelfApproval]]
+=== `label.Label-Name.ignoreSelfApproval`
+
+If true, the label may be voted on by the uploader of the latest patch set,
+but their approval does not make a change submittable. Instead, a
+non-uploader who has the right to vote has to approve the change.
+
+Defaults to false.
 
 [[label_example]]
 === Example
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 3b2b65f..4456484 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -127,7 +127,11 @@
 +
 Controls whether or not the Change-Id must be included in the commit message
 in the last paragraph. Default is `INHERIT`, which means that this property
-is inherited from the parent project.
+is inherited from the parent project. The global default for new hosts
+is `true`
++
+This option is deprecated and future releases will behave as if this
+is always `true`.
 
 [[receive.maxObjectSizeLimit]]receive.maxObjectSizeLimit::
 +
@@ -136,12 +140,18 @@
 operation will fail. If set to zero then there is no limit.
 +
 Project owners can use this setting to prevent developers from pushing
-objects which are too large to Gerrit. This setting can also be set it
-`gerrit.config` globally link:config-gerrit.html#receive.maxObjectSizeLimit[
-receive.maxObjectSizeLimit].
+objects which are too large to Gerrit. This setting can also be set in
+`gerrit.config` globally (link:config-gerrit.html#receive.maxObjectSizeLimit[
+receive.maxObjectSizeLimit]).
 +
-The project specific setting in `project.config` is only honored when it
-further reduces the global limit.
+The project specific setting in `project.config` may not set a value higher
+than the global limit (if configured). In other words, it is only honored when
+it further reduces the global limit.
++
+When link:config-gerrit.html#receive.inheritProjectMaxObjectSizeLimit[
+`receive.inheritProjectmaxObjectSizeLimit`] is enabled in the global config,
+the value is inherited from the parent project. Otherwise, it is not inherited
+and must be explicitly set per project.
 +
 Default is zero.
 +
@@ -176,9 +186,10 @@
 +
 Controls whether server-side signed push validation is required on the
 project. Only has an effect if signed push validation is enabled on the
-server, and link:#receive.enableSignedPush is set on the project. See
-the link:config-gerrit.html#receive.enableSignedPush[global
-configuration] for details.
+server, and link:#receive.enableSignedPush[`receive.enableSignedPush`] is
+set on the project. See the
+link:config-gerrit.html#receive.enableSignedPush[global configuration]
+for details.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
@@ -217,6 +228,19 @@
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
 
+[[change.workInProgressByDefault]]change.workInProgressByDefault::
++
+Controls whether all new changes in the project are set as WIP by default.
++
+Note that a new change will be ready if the `workInProgress` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `ready` link:user-upload.html#wip[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
 [[submit-section]]
 === Submit section
 
@@ -238,7 +262,8 @@
 
 - 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
 Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail.
+commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
+the initial commit on a branch.
 
 Merge strategy
 
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index d35772e..2153751 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -16,7 +16,8 @@
 with the database while Gerrit is offline, it's not easy to backup the data,
 and it's not possible to set up H2 in a load balanced/hotswap configuration.
 
-If this option interests you, you might want to consider link:install-quick.html[the quick guide].
+If this option interests you, you might want to consider
+link:linux-quickstart.html[the quick guide].
 
 [[createdb_derby]]
 === Apache Derby
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 055ebcb..6331581 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -14,13 +14,34 @@
 * zip, unzip
 * gcc
 
+[[Java 9 support]]
+Java 9 is supported through alternative java toolchain
+link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
+The Java 9 support is backwards compatible. Java 8 is still the default.
+To build Gerrit with Java 9, specify JDK 9 java toolchain:
+
+```
+  $ bazel build \
+      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
+      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java9 \
+      :release
+```
+
+Note that the following option must be added to `container.javaOptions`
+in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 9:
+
+```
+[container]
+  javaOptions = --add-modules java.activation \
+      --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+```
+
 [[build]]
 == Building on the Command Line
 
 === Gerrit Development WAR File
 
-To build the Gerrit web application that includes the GWT UI and the
-PolyGerrit UI:
+To build the Gerrit web application that includes the PolyGerrit UI:
 
 ----
   bazel build gerrit
@@ -54,7 +75,8 @@
 
 === Headless Mode
 
-To build Gerrit in headless mode, i.e. without the GWT Web UI:
+To build Gerrit in headless mode, i.e. without the PolyGerrit and GWT
+Web UI:
 
 ----
   bazel build headless
@@ -126,6 +148,11 @@
 Note that when building an individual plugin, the `core.zip` package
 is not regenerated.
 
+To build with all Error Prone warnings activated, run:
+
+----
+  bazel build --java_toolchain //tools:error_prone_warnings_toolchain //...
+----
 
 
 [[IDEs]]
@@ -266,7 +293,9 @@
 
 * annotation
 * api
+* docker
 * edit
+* elastic
 * git
 * notedb
 * pgm
@@ -277,11 +306,13 @@
 [[elasticsearch]]
 === Elasticsearch
 
-Successfully running the elasticsearch tests may require setting the local
+Successfully running the Elasticsearch tests requires Docker, and
+may require setting the local
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[virtual memory].
 
-Bazel link:https://github.com/bazelbuild/bazel/issues/3476[does not currently make container failures visible],
-if any.
+If Docker is not available, the Elasticsearch tests will be skipped.
+Note that Bazel currently does not show
+link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests].
 
 == Dependencies
 
@@ -391,6 +422,92 @@
 details. Users should watch the cache sizes and clean them manually if
 necessary.
 
+[[npm-binary]]
+== NPM Binaries
+
+Parts of the PolyGerrit build require running NPM-based JavaScript programs as
+"binaries". We don't attempt to resolve and download NPM dependencies at build
+time, but instead use pre-built bundles of the NPM binary along with all its
+dependencies. Some packages on
+link:https://docs.npmjs.com/misc/registry[registry.npmjs.org] come with their
+dependencies bundled, but this is the exception rather than the rule. More
+commonly, to add a new binary to this list, you will need to bundle the binary
+yourself.
+
+[NOTE]
+We can only use binaries that meet certain licensing requirements, and that do
+not include any native code.
+
+Start by checking that the license and file types of the bundle are acceptable:
+[source,bash]
+----
+  gerrit_repo=/path/to/gerrit
+  package=some-npm-package
+  version=1.2.3
+
+  npm install -g license-checker && \
+  rm -rf /tmp/$package-$version && mkdir -p /tmp/$package-$version && \
+  cd /tmp/$package-$version && \
+  npm install $package@$version && \
+  license-checker | grep licenses: | sort -u
+----
+
+This will output a list of the different licenses used by the package and all
+its transitive dependencies. We can only legally distribute a bundle via our
+storage bucket if the licenses allow us to do so. As long as all of the listed
+license are allowed by
+link:https://opensource.google.com/docs/thirdparty/licenses/[Google's
+standards]. Any `by_exception_only`, commercial, prohibited, or unlisted
+licenses are not allowed; otherwise, it is ok to distribute the source. If in
+doubt, contact a maintainer who is a Googler.
+
+Next, check the file types:
+[source,bash]
+----
+  cd /tmp/$package-$version
+  find . -type f | xargs file | grep -v 'ASCII\|UTF-8\|empty$'
+----
+
+If you see anything that looks like a native library or binary, then we can't
+use the bundle.
+
+If everything looks good, create the bundle, and note the SHA-1:
+[source,bash]
+----
+  $gerrit_repo/tools/js/npm_pack.py $package $version && \
+  sha1sum $package-$version.tgz
+----
+
+This creates a file named `$package-$version.tgz` in your working directory.
+
+Any project maintainer can upload this file to the
+link:https://console.cloud.google.com/storage/browser/gerrit-maven/npm-packages[storage
+bucket].
+
+Finally, add the new binary to the build process:
+----
+  # WORKSPACE
+  npm_binary(
+      name = "some-npm-package",
+      repository = GERRIT,
+  )
+
+  # lib/js/npm.bzl
+  NPM_VERSIONS = {
+    ...
+    "some-npm-package": "1.2.3",
+  }
+
+  NPM_SHA1S = {
+    ...
+    "some-npm-package": "<sha1>",
+  }
+----
+
+To use the binary from the Bazel build, you need to use the `run_npm_binary.py`
+wrapper script. For an example, see the use of `crisper` in `tools/bzl/js.bzl`.
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 8acd663..bc9f782 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -164,9 +164,9 @@
 
 To format Java source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
-tool (version 1.5), and to format Bazel BUILD and WORKSPACE files the
+tool (version 1.6), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
-tool (version 0.11.1).
+tool (version 0.15.0).
 These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
 and contentious discussions about trivial issues like whitespace.
@@ -368,6 +368,36 @@
 that they are vetted long enough before they go into a release and we can be sure
 that the update doesn't introduce a regression.
 
+[[deprecating-features]]
+=== Deprecating features
+
+Gerrit should be as stable as possible and we aim to add only features that last.
+However, sometimes we are required to deprecate and remove features to be able
+to move forward with the project and keep the code-base clean. The following process
+should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
+is that we have a structured process for deprecation that users, administrators and
+developers can agree and rely on.
+
+General process:
+* Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
+  further development or improvement. If in doubt, consult the mailing list.
+* If you can provide a schema migration that moves users to a comparable feature, do
+  so and stop here.
+* Mark the feature as deprecated in the documentation and release notes.
+* If possible, mark the feature deprecated in any user-visible interface. For example,
+  if you are deprecating a Git push option, add a message to the Git response if
+  the user provided the option informing them about deprecation.
+* Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
+  Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
+  number that has to be branched off before removing the feature)
+* Gate the feature behind a config that is off by default (forcing admins to turn
+  the deprecated feature on explicitly).
+* After the next release was branched off, remove any code that backed the feature.
+
+You can optionally consult the mailing list to ask if there are users of the feature you
+wish to deprecate. If there are no major users, you can remove the feature without
+following this process and without the grace period of one release.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 6e39502..0f23db5 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -3,7 +3,7 @@
 This document is about configuring Gerrit Code Review into an
 Eclipse workspace for development and debugging with GWT.
 
-Java 6 or later SDK is also required to run GWT's compiler and
+Java 8 or later SDK is also required to run GWT's compiler and
 runtime debugging environment.
 
 
@@ -49,6 +49,19 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
+[[Newer Java versions]]
+
+Java 9 and later are supported, but some adjustments must be done, because
+Java 8 is still the default:
+
+* Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
+* Change execution environemnt for gerrit project to: JavaSE-9 (java-9-openjdk-9)
+* Check that compiler compliance level in gerrit project is set to: 9
+* Add this parameter to VM argument for gerrit_daemin launcher:
+----
+  --add-modules java.activation \
+  --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+----
 
 [[Formatting]]
 == Code Formatter Settings
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index a59c08d..6517262 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -109,6 +109,24 @@
   Gerrit-HttpModule: tld.example.project.HttpModuleClassName
 ----
 
+=== Batch runtime
+
+Gerrit can be run as a server, serving HTTP or SSH requests, or as an
+offline program. Plugins can contribute Guice modules to this batch
+runtime by binding `Gerrit-BatchModule` to one of their classes.
+The Guice injector is bound to less classes, and some Gerrit features
+will be absent - on purpose.
+
+This feature was originally introduced to support plugins during an
+offline reindexing task.
+
+----
+  Gerrit-BatchModule: tld.example.project.CoreModuleClassName
+----
+
+In this runtime, only the module designated by `Gerrit-BatchModule` is
+enabled, not `Gerrit-SysModule`.
+
 [[plugin_name]]
 === Plugin Name
 
@@ -2295,6 +2313,16 @@
 e.g. a plugin can provide a list of servers on which the change was
 deployed.
 
+[[change-report-formatting]]
+== Change Report Formatting
+
+When a change is pushed for review from the command line, Gerrit reports
+the change(s) received with their URL and subject.
+
+By implementing the
+`com.google.gerrit.server.git.ChangeReportFormatter` interface, a plugin
+may change the formatting of the report.
+
 [[links-to-external-tools]]
 == Links To External Tools
 
@@ -2506,7 +2534,7 @@
 attribute.
 
 Documentation may be written in the Markdown flavor
-link:https://github.com/sirthias/pegdown[pegdown]
+link:https://github.com/vsch/flexmark-java[flexmark-java]
 if the file name ends with `.md`. Gerrit will automatically convert
 Markdown to HTML if accessed with extension `.html`.
 
@@ -2706,7 +2734,7 @@
 [source, java]
 ----
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 
 public class MyPlugin implements MailFilter {
   public boolean shouldProcessMessage(MailMessage message) {
@@ -2840,6 +2868,20 @@
 }
 ----
 
+Plugin authors should also consider binding their SubmitRule using a `Gerrit-BatchModule`.
+See link:dev-plugins.html[Batch runtime] for more informations.
+
+
+The SubmitRule extension point allows you to write complex rules, but writing
+small self-contained rules should be preferred: doing so allows end users to
+compose several rules to form more complex submit checks.
+
+The `SubmitRequirement` class allows rules to communicate what the user needs
+to change in order to be compliant. These requirements should be kept once they
+are met, but marked as `OK`. If the requirements were not displayed, reviewers
+would need to use their precious time to manually check that they were met.
+
+
 == SEE ALSO
 
 * link:js-api.html[JavaScript API]
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
index 79049fc..5621d32 100644
--- a/Documentation/dev-polygerrit.txt
+++ b/Documentation/dev-polygerrit.txt
@@ -1,12 +1,15 @@
 = PolyGerrit - GUI
 
-[IMPORTANT]
-PolyGerrit is still a beta feature. Some features may be missing.
-
 == Configuring
 
 By default both GWT and PolyGerrit UI are available to users.
 
+To make PolyGerrit the default UI but keep GWT as a secondary UI:
+----
+[gerrit]
+        ui = POLYGERRIT
+----
+
 To disable GWT but not PolyGerrit:
 ----
 [gerrit]
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 53ded48..0abd8a1 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -316,15 +316,15 @@
 ==== Announce on Mailing List
 
 Send an email to the mailing list to announce the release. The content of the
-announcement email is generated with the `release-announcement.py` which
-automatically includes all the necessary links, hash values, and wraps the
-text in a PGP signature.
+announcement email is generated with the `release-announcement.py` script from
+the gerrit-release-tools repository, which automatically includes all the
+necessary links, hash values, and wraps the text in a PGP signature.
 
 For details refer to the documentation in the script's header, and/or the
 help text:
 
 ----
- ./tools/release-announcement.py --help
+ ~/gerrit-release-tools/release-announcement.py --help
 ----
 
 [[increase-version]]
@@ -365,6 +365,17 @@
 must reference the new version. Upload a change to bazlets repository with
 api version upgrade.
 
+[[clean-up-on-master]]
+=== Clean up on master
+
+Once you are done with the release, check if there are any code changes in the
+master branch that were gated on the next release. Mostly, these are
+feature-deprecations that we were holding off on to have a stable release where
+the feature is still contained, but marked as deprecated.
+
+See link:dev-contributing.html#deprecating-features[Deprecating features] for
+details.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/error-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index 9cddd85..08f2c09 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -1,4 +1,4 @@
-= missing Change-Id in commit message footer
+= commit xxxxxxx: missing Change-Id in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-missing-subject.txt b/Documentation/error-missing-subject.txt
index 3703ade..6ef37a4 100644
--- a/Documentation/error-missing-subject.txt
+++ b/Documentation/error-missing-subject.txt
@@ -1,4 +1,4 @@
-= missing subject; Change-Id must be in commit message footer
+= commit xxxxxxx: missing subject; Change-Id must be in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-multiple-changeid-lines.txt b/Documentation/error-multiple-changeid-lines.txt
index 0729547..31567f4 100644
--- a/Documentation/error-multiple-changeid-lines.txt
+++ b/Documentation/error-multiple-changeid-lines.txt
@@ -1,4 +1,4 @@
-= multiple Change-Id lines in commit message footer
+= commit xxxxxxx: multiple Change-Id lines in message footer
 
 With this error message Gerrit rejects to push a commit if the commit
 message footer of the pushed commit contains several Change-Id lines.
diff --git a/Documentation/images/inline-edit-add-file-page.png b/Documentation/images/inline-edit-add-file-page.png
new file mode 100644
index 0000000..1a761b4
--- /dev/null
+++ b/Documentation/images/inline-edit-add-file-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change-form.png b/Documentation/images/inline-edit-create-change-form.png
new file mode 100644
index 0000000..7a93460
--- /dev/null
+++ b/Documentation/images/inline-edit-create-change-form.png
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change.png b/Documentation/images/inline-edit-create-change.png
new file mode 100644
index 0000000..1df0421
--- /dev/null
+++ b/Documentation/images/inline-edit-create-change.png
Binary files differ
diff --git a/Documentation/images/inline-edit-delete-file.png b/Documentation/images/inline-edit-delete-file.png
new file mode 100644
index 0000000..1634e0f
--- /dev/null
+++ b/Documentation/images/inline-edit-delete-file.png
Binary files differ
diff --git a/Documentation/images/inline-edit-diff-screen.png b/Documentation/images/inline-edit-diff-screen.png
new file mode 100644
index 0000000..228484a
--- /dev/null
+++ b/Documentation/images/inline-edit-diff-screen.png
Binary files differ
diff --git a/Documentation/images/inline-edit-home-page.png b/Documentation/images/inline-edit-home-page.png
new file mode 100644
index 0000000..a1b8eb4
--- /dev/null
+++ b/Documentation/images/inline-edit-home-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-new-change-page.png b/Documentation/images/inline-edit-new-change-page.png
new file mode 100644
index 0000000..8a33dd6
--- /dev/null
+++ b/Documentation/images/inline-edit-new-change-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-open-file.png b/Documentation/images/inline-edit-open-file.png
new file mode 100644
index 0000000..a5422f5
--- /dev/null
+++ b/Documentation/images/inline-edit-open-file.png
Binary files differ
diff --git a/Documentation/images/inline-edit-prefill-files.png b/Documentation/images/inline-edit-prefill-files.png
new file mode 100644
index 0000000..0b2b766
--- /dev/null
+++ b/Documentation/images/inline-edit-prefill-files.png
Binary files differ
diff --git a/Documentation/images/inline-edit-review-message.png b/Documentation/images/inline-edit-review-message.png
new file mode 100644
index 0000000..bd76fad
--- /dev/null
+++ b/Documentation/images/inline-edit-review-message.png
Binary files differ
diff --git a/Documentation/images/inline-edit-start-review-button.png b/Documentation/images/inline-edit-start-review-button.png
new file mode 100644
index 0000000..df6350b
--- /dev/null
+++ b/Documentation/images/inline-edit-start-review-button.png
Binary files differ
diff --git a/Documentation/images/intro-quick-new-review.jpg b/Documentation/images/intro-quick-new-review.jpg
deleted file mode 100644
index 99e6c55..0000000
--- a/Documentation/images/intro-quick-new-review.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-new-review.png b/Documentation/images/intro-quick-new-review.png
new file mode 100644
index 0000000..36d93e9
--- /dev/null
+++ b/Documentation/images/intro-quick-new-review.png
Binary files differ
diff --git a/Documentation/images/intro-quick-review-2-patches.jpg b/Documentation/images/intro-quick-review-2-patches.jpg
deleted file mode 100644
index 29c99cc..0000000
--- a/Documentation/images/intro-quick-review-2-patches.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-review-2-patches.png b/Documentation/images/intro-quick-review-2-patches.png
new file mode 100644
index 0000000..d7e9129
--- /dev/null
+++ b/Documentation/images/intro-quick-review-2-patches.png
Binary files differ
diff --git a/Documentation/images/intro-quick-review-line-comment.jpg b/Documentation/images/intro-quick-review-line-comment.jpg
deleted file mode 100644
index eeb144a..0000000
--- a/Documentation/images/intro-quick-review-line-comment.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-review-line-comment.png b/Documentation/images/intro-quick-review-line-comment.png
new file mode 100644
index 0000000..7964365
--- /dev/null
+++ b/Documentation/images/intro-quick-review-line-comment.png
Binary files differ
diff --git a/Documentation/images/intro-quick-reviewing-the-change.jpg b/Documentation/images/intro-quick-reviewing-the-change.jpg
deleted file mode 100644
index bfded9e..0000000
--- a/Documentation/images/intro-quick-reviewing-the-change.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-reviewing-the-change.png b/Documentation/images/intro-quick-reviewing-the-change.png
new file mode 100644
index 0000000..bdce6bd
--- /dev/null
+++ b/Documentation/images/intro-quick-reviewing-the-change.png
Binary files differ
diff --git a/Documentation/images/intro-quick-verifying.jpg b/Documentation/images/intro-quick-verifying.jpg
deleted file mode 100644
index 7679c0a..0000000
--- a/Documentation/images/intro-quick-verifying.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-verifying.png b/Documentation/images/intro-quick-verifying.png
new file mode 100644
index 0000000..e343cc9
--- /dev/null
+++ b/Documentation/images/intro-quick-verifying.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 51ce9d6..6011158 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -5,6 +5,7 @@
 . link:linux-quickstart.html[Quickstart for Installing Gerrit on Linux]
 
 == About Gerrit
+. link:intro-rockstar.html[Why Code Review?]
 . link:intro-quick.html[Product Overview]
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
@@ -66,6 +67,7 @@
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
+. link:user-request-tracing.html[Request Tracing]
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
deleted file mode 100644
index 7b80229..0000000
--- a/Documentation/install-quick.txt
+++ /dev/null
@@ -1,227 +0,0 @@
-= Gerrit Code Review - Quick get started guide
-
-****
-This guide was made with the impatient in mind, ready to try out Gerrit on their
-own server but not prepared to make the full installation procedure yet.
-
-Explanation is sparse and you should not use a server installed this way in a
-live setup, this is made with proof of concept activities in mind.
-
-It is presumed you install it on a Unix based server such as any of the Linux
-flavors or BSD.
-
-It's also presumed that you have access to an OpenID enabled email address.
-Examples of OpenID enable email providers are Gmail, Yahoo! Mail and Hotmail.
-It's also possible to register a custom email address with OpenID, but that is
-outside the scope of this quick installation guide. For testing purposes one of
-the above providers should be fine. Please note that network access to the
-OpenID provider you choose is necessary for both you and your Gerrit instance.
-****
-
-
-[[requirements]]
-== Requirements
-
-Most distributions come with Java today. Do you already have Java installed?
-
-----
-  $ java -version
-  openjdk version "1.8.0_72"
-  OpenJDK Runtime Environment (build 1.8.0_72-b15)
-  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
-----
-
-If Java isn't installed, get it:
-
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
-
-
-[[user]]
-== Create a user to host the Gerrit service
-
-We will run the service as a non-privileged user on your system.
-First create the user and then become the user:
-
-----
-  $ sudo adduser gerrit
-  $ sudo su gerrit
-----
-
-If you don't have root privileges you could skip this step and run Gerrit
-as your own user as well.
-
-
-[[download]]
-== Download Gerrit
-
-It's time to download the archive that contains the Gerrit web and ssh service.
-
-You can choose from different versions to download from here:
-
-* https://www.gerritcodereview.com/download/index.html[A list of releases available]
-
-This tutorial is based on version 2.2.2, and you can download that from this link
-
-* https://www.gerritcodereview.com/download/gerrit-2.2.2.war[Link to the 2.2.2 war archive]
-
-
-[[initialization]]
-== Initialize the Site
-
-It's time to run the initialization, and with the batch switch enabled, we don't have to answer any questions at all:
-
-----
-  gerrit@host:~$ java -jar gerrit.war init --batch -d ~/gerrit_testsite
-  Generating SSH host key ... rsa(simple)... done
-  Initialized /home/gerrit/gerrit_testsite
-  Executing /home/gerrit/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-When the init is complete, you can review your settings in the
-file `'$site_path/etc/gerrit.config'`.
-
-Note that initialization also starts the server.  If any settings changes are
-made, the server must be restarted before they will take effect.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh restart
-  Stopping Gerrit Code Review: OK
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-The server can be also stopped and started by passing the `stop` and `start`
-commands to gerrit.sh.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh stop
-  Stopping Gerrit Code Review: OK
-  gerrit@host:~$
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-include::config-login-register.txt[]
-
-== Project creation
-
-Your base Gerrit server is now running and you have a user that's ready
-to interact with it.  You now have two options, either you create a new
-test project to work with or you already have a git with history that
-you would like to import into Gerrit and try out code review on.
-
-=== New project from scratch
-If you choose to create a new repository from scratch, it's easier for
-you to create a project with an initial commit in it. That way first
-time setup between client and server is easier.
-
-This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project --empty-commit
-  user@host:~$
-----
-
-This will create a repository that you can clone to work with.
-
-=== Already existing project
-
-The other alternative is if you already have a git project that you
-want to try out Gerrit on.
-First you have to create the project.  This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project
-  user@host:~$
-----
-
-You need to make sure that at least initially your account is granted
-"Create Reference" privileges for the refs/heads/* reference.
-This is done via the web interface in the Admin/Projects/Access page
-that correspond to your project.
-
-After that it's time to upload the previous history to the server:
-
-----
-  user@host:~/my-project$ git push ssh://user@localhost:29418/demo-project *:*
-  Counting objects: 2011, done.
-  Writing objects: 100% (2011/2011), 456293 bytes, done.
-  Total 2011 (delta 0), reused 0 (delta 0)
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      master -> master
-  user@host:~/my-project$
-----
-
-This will create a repository that you can clone to work with.
-
-
-== My first change
-
-Download a local clone of the repository and move into it
-
-----
-  user@host:~$ git clone ssh://user@localhost:29418/demo-project
-  Cloning into demo-project...
-  remote: Counting objects: 2, done
-  remote: Finding sources: 100% (2/2)
-  remote: Total 2 (delta 0), reused 0 (delta 0)
-  user@host:~$ cd demo-project
-  user@host:~/demo-project$
-----
-
-Then make a change to it and upload it as a reviewable change in Gerrit.
-
-----
-  user@host:~/demo-project$ date > testfile.txt
-  user@host:~/demo-project$ git add testfile.txt
-  user@host:~/demo-project$ git commit -m "My pretty test commit"
-  [master ff643a5] My pretty test commit
-   1 files changed, 1 insertions(+), 0 deletions(-)
-   create mode 100644 testfile.txt
-  user@host:~/demo-project$
-----
-
-Usually when you push to a remote git, you push to the reference
-`'/refs/heads/branch'`, but when working with Gerrit you have to push to a
-virtual branch representing "code review before submission to branch".
-This virtual name space is known as /refs/for/<branch>
-
-----
-  user@host:~/demo-project$ git push origin HEAD:refs/for/master
-  Counting objects: 4, done.
-  Writing objects: 100% (3/3), 293 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  remote:
-  remote: New Changes:
-  remote:   http://localhost:8080/1
-  remote:
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      HEAD -> refs/for/master
-  user@host:~/demo-project$
-----
-
-You should now be able to access your change by browsing to the http URL
-suggested above, http://localhost:8080/1
-
-
-== Quick Installation Complete
-
-This covers the scope of getting Gerrit started and your first change uploaded.
-It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
-learn more about the workflow of Gerrit.
-
-To read more on the installation of Gerrit please see link:install.html[the detailed
-installation page].
-
-
-GERRIT
-------
-
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index cc19b3f..dbca368 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,9 @@
 
 To run the Gerrit service, the following requirement must be met on the host:
 
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
++
+Gerrit is not yet compatible with Java 9 or newer at this time.
 
 By default, Gerrit uses link:note-db.html[NoteDB] as the storage backend. (If
 desired, you can _optionally_ use an external database such as MySQL or
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index fcb4de2..1fba1dc 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -4,7 +4,7 @@
 life cycle. This example uses a Gerrit server configured as follows:
 
 * *Hostname*: gerrithost
-* *HTTP interface port*: 8080
+* *HTTP interface port*: 80
 * *SSH interface port*: 29418
 
 In this walkthrough, we'll follow two developers, Max and Hannah, as they make
@@ -52,19 +52,20 @@
 ----
 $ <work>
 $ git commit
-[master 9651f22] Change to a proper, yeast based pizza dough.
- 1 files changed, 3 insertions(+), 2 deletions(-)
+[master 3cc9e62] Change to a proper, yeast based pizza dough.
+ 1 file changed, 10 insertions(+), 5 deletions(-)
 $ git push origin HEAD:refs/for/master
-Counting objects: 5, done.
+Counting objects: 3, done.
 Delta compression using up to 8 threads.
 Compressing objects: 100% (2/2), done.
-Writing objects: 100% (3/3), 542 bytes, done.
+Writing objects: 100% (3/3), 532 bytes | 0 bytes/s, done.
 Total 3 (delta 0), reused 0 (delta 0)
+remote: Processing changes: new: 1, done
 remote:
 remote: New Changes:
-remote:   http://gerrithost:8080/68
+remote:   http://gerrithost/#/c/RecipeBook/+/702 Change to a proper, yeast based pizza dough.
 remote:
-To ssh://gerrithost:29418/RecipeBook.git
+To ssh://gerrithost:29418/RecipeBook
  * [new branch]      HEAD -> refs/for/master
 ----
 
@@ -79,7 +80,7 @@
 the following.
 
 .Gerrit Code Review Screen
-image::images/intro-quick-new-review.jpg[Gerrit Review Screen]
+image::images/intro-quick-new-review.png[Gerrit Review Screen]
 
 This is the Gerrit code review screen, where other contributors can review
 his change. Max can also perform tasks such as:
@@ -109,14 +110,12 @@
 Because Max added Hannah as a reviewer, she receives an email telling her about
 his change. She opens up the Gerrit code review screen and selects Max's change.
 
-.Gerrit Code Review Screen
-image::images/intro-quick-new-review.jpg[Gerrit Review Screen]
-
-Notice the two "Need" lines:
+Notice the *Label status* section above:
 
 ----
-* Need Verified
-* Need Code-Review
+Label Status Needs label:
+             * Code-Review
+             * Verified
 ----
 
 These two lines indicate what checks must be completed before the change is
@@ -147,13 +146,13 @@
 Hannah opts to view the change using Gerrit's side-by-side view:
 
 .Side By Side Patch View
-image::images/intro-quick-review-line-comment.jpg[Adding a Comment]
+image::images/intro-quick-review-line-comment.png[Adding a Comment]
 
 Hannah reviews the change and is ready to provide her feedback. She clicks the
-*Review* button on the change screen. This allows her to vote on the change.
+*REPLY* button on the change screen. This allows her to vote on the change.
 
 .Reviewing the Change
-image::images/intro-quick-reviewing-the-change.jpg[Reviewing the Change]
+image::images/intro-quick-reviewing-the-change.png[Reviewing the Change]
 
 For Hannah and Max's team, a code review vote is a numerical score between -2
 and 2. The possible options are:
@@ -175,7 +174,7 @@
 Hannah notices a possible issue with Max's change, so she selects a `-1` vote.
 She uses the *Cover Message* text box to provide Max with some additional
 feedback. When she is satisfied with her review, Hannah clicks the
-*Publish Comments* button. At this point, her vote and cover message become
+*SEND* button. At this point, her vote and cover message become
 visible to to all users.
 
 == Reworking the Change
@@ -193,18 +192,21 @@
 $ <checkout first commit>
 $ <rework>
 $ git commit --amend
+[master 30a6f44] Change to a proper, yeast based pizza dough.
+ Date: Fri Jun 8 16:28:23 2018 +0200
+ 1 file changed, 10 insertions(+), 5 deletions(-)
 $ git push origin HEAD:refs/for/master
-Counting objects: 5, done.
+Counting objects: 3, done.
 Delta compression using up to 8 threads.
 Compressing objects: 100% (2/2), done.
-Writing objects: 100% (3/3), 546 bytes, done.
+Writing objects: 100% (3/3), 528 bytes | 0 bytes/s, done.
 Total 3 (delta 0), reused 0 (delta 0)
 remote: Processing changes: updated: 1, done
 remote:
 remote: Updated Changes:
-remote:   http://gerrithost:8080/68
+remote:   http://gerrithost/#/c/RecipeBook/+/702 Change to a proper, yeast based pizza dough.
 remote:
-To ssh://gerrithost:29418/RecipeBook.git
+To ssh://gerrithost:29418/RecipeBook
  * [new branch]      HEAD -> refs/for/master
 ----
 
@@ -212,13 +214,10 @@
 commit. This time, the output verifies that the change was updated.
 
 Having uploaded the reworked commit, Max can go back to the Gerrit web
-interface and look at his change.
-
-.Reviewing the Rework
-image::images/intro-quick-review-2-patches.jpg[Reviewing the Rework]
-
-Notice that there are now two patch sets associated with this change: the
-initial submission and the rework.
+interface, look at his change and diff the first patch set with his rework in
+the second one. Once he has verified that the rework follows Hannahs
+recommendation he presses the *DONE* button to let Hannah know that she can
+review the changes.
 
 When Hannah next looks at Max's change, she sees that he incorporated her
 feedback. The change looks good to her, so she changes her vote to a `+2`.
@@ -254,7 +253,7 @@
 different person entirely.
 
 .Verifying the Change
-image::images/intro-quick-verifying.jpg[Verifying the Change]
+image::images/intro-quick-verifying.png[Verifying the Change]
 
 Unlike the code review check, the verify check is pass/fail. Hannah can provide
 a score of either `+1` or `-1`. A change must have at least one `+1` and no
@@ -266,7 +265,7 @@
 == Submitting the Change
 
 Max is now ready to submit his change. He opens up the change in the Code Review
-screen and clicks the *Publish and Submit* button.
+screen and clicks the *SUBMIT* button.
 
 At this point, Max's change is merged into the repository's master branch and
 becomes an accepted part of the project.
diff --git a/Documentation/intro-how-gerrit-works.txt b/Documentation/intro-how-gerrit-works.txt
index f903849..5f5deed 100644
--- a/Documentation/intro-how-gerrit-works.txt
+++ b/Documentation/intro-how-gerrit-works.txt
@@ -1,32 +1,32 @@
 = How Gerrit Works
 
-To understand how Gerrit fits into and enhances the developer workflow, consider
-a typical project. This project has a central source repository, which serves as
-the authoritative copy of the project's contents.
+To learn how Gerrit fits into and complements the developer workflow, consider
+a typical project. The following project contains a central source repository
+(_Authoritative Repository_) that serves as the authoritative version of the
+project's contents.
 
 .Central Source Repository
 image::images/intro-quick-central-repo.png[Authoritative Source Repository]
 
-Gerrit takes the place of this central repository and adds an additional
-concept: a _store of pending changes_.
+When implemented, Gerrit becomes the central source repository and introduces
+an additional concept: a store of _Pending Changes_.
 
-.Gerrit in place of Central Repository
-image::images/intro-quick-central-gerrit.png[Gerrit in place of Central Repository]
+.Gerrit as the Central Repository
+image::images/intro-quick-central-gerrit.png[Gerrit as the Central Repository]
 
-With Gerrit, when a developer makes a change, it is sent to this store of
-pending changes, where other developers can review, discuss and approve the
-change. After enough reviewers grant their approval, the change becomes an
-official part of the codebase.
+When Gerrit is configured as the central source repository, all code changes
+are sent to Pending Changes for others to review and discuss. When enough
+reviewers have approved a code change, you can submit the change to the code
+base.
 
-In addition to this store of pending changes, Gerrit captures notes
-and comments about each change. These features allow developers to review
-changes at their convenience, or when conversations about a change can't
-happen face to face. They also help to create a record of the conversation
-around a given change, which can provide a history of when a change was made and
-why.
+In addition to the store of Pending Changes, Gerrit captures notes and comments
+made about each change. This enables you to review changes at your convenience
+or when a conversation about a change can't happen in person. In addition,
+notes and comments provide a history of each change (what was changed and why and
+who reviewed the change).
 
-Like any repository hosting solution, Gerrit has a powerful
-link:access-control.html[access control model]. This model allows you to
+Like any repository hosting product, Gerrit provides a powerful
+link:access-control.html[access control model], which enables you to
 fine-tune access to your repository.
 
 GERRIT
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index e6b1e43..11d5052 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -1,31 +1,47 @@
-= Gerrit Product Overview
+= Gerrit Code Review Product Overview
 
-Gerrit is a web-based code review tool built on top of the
-https://git-scm.com/[Git version control system]. This introduction provides
-an overview of Gerrit and describes how Gerrit integrates into a typical
-development workflow. It also provides a brief tutorial that shows how to manage
-a change using Gerrit.
+Gerrit Code Review is a web-based code review tool built on
+https://git-scm.com/[Git version control].
 
-== What is Gerrit?
+== What is Gerrit Code Review?
 
-Gerrit makes code review easy by providing a lightweight framework for reviewing
-commits before they are accepted by the codebase. Gerrit works equally well for
-projects where approving changes is restricted to selected users, as is typical
-for Open Source software development, as well as projects where all contributors
-are trusted.
+Gerrit provides a framework you and your teams can use to review code before it
+becomes part of the code base. Gerrit works equally well in open source projects
+that limit the number of users who can approve changes (typical in open source
+software development) and in projects in which all contributors are trusted.
 
-== Learn About Gerrit
+== What is Code Review?
 
-If you're new to Gerrit and want to know more about how it can improve your
-developer workflow, see the following topics:
+Code reviews can identify mistakes before they're found by customers. In a world
+of continuous integration, code must be tested before it's submitted to the
+master branch to become part of the code base. Tests confirm that a product
+works (and continues to work) as intended by the developers.
+
+When code is reviewed, developers:
+
+. Work carefully and consistently
+. Learn best practices and new techniques from other developers
+. Implement consistency and quality across the code base
+
+Code reviews typically turn up issues related to:
+
+. Design: Is code well-designed and suited to the code base?
+. Functionality: Does code perform as intended and in a way that is good for users?
+. Complexity: Can other developers understand and use the code?
+. Naming: Does the code contain clear names for elements such as variables, classes, and methods?
+. Comments: Are comments specific and complete?
+
+== Learn Gerrit Code Review
+
+If you're new to Gerrit and want to learn how Gerrit can improve your workflow,
+see:
 
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
 
 == Getting Started
 
-This documentation contains several guides to help you learn about the Gerrit
-features most relevant to you:
+To learn more, see:
 
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
diff --git a/Documentation/intro-rockstar.txt b/Documentation/intro-rockstar.txt
new file mode 100644
index 0000000..b60a91f
--- /dev/null
+++ b/Documentation/intro-rockstar.txt
@@ -0,0 +1,129 @@
+= Use Gerrit to Be a Rockstar Programmer
+
+== Overview
+
+The term _rockstar_ is often used to describe those talented programmers who
+seem to work faster and better than everyone else, much like a composer who
+seems to effortlessly churn out fantastic music. However, just as the
+spontaneity of masterful music is a fantasy, so is the development of
+exceptional code.
+
+The process of composing and then recording music is painstaking — the artist
+records portions of a composition over and over, changing each take until one
+song is completed by combining those many takes into a cohesive whole. The end
+result is the recording of the best performance of the best version of the
+song.
+
+Consider Queen’s six-minute long Bohemian Rhapsody, which took three weeks to
+record. Some segments were overdubbed 180 times!
+
+Software engineering is much the same. Changes that seem logical and
+straightforward in retrospect actually required many revisions and many hours
+of work before they were ready to be merged into a code base. A single
+conceptual code change (_fix bug 123_) often requires numerous iterations
+before it can be finalized. Programmers typically:
+
+* Fix compilation errors
+* Factor out a method, to avoid duplicate code
+* Use a better algorithm, to make it faster
+* Handle error conditions, to make it more robust
+* Add tests, to prevent a bug from regressing
+* Adapt tests, to reflect changed behavior
+* Polish code, to make it easier to read
+* Improve the commit message, to explain why a change was made
+
+In fact, first drafts of code changes are best kept out of project history. Not
+just because rockstar programmers want to hide sloppy first attempts at making
+something work. It's more that keeping intermediate states hampers effective
+use of version control. Git works best when one commit corresponds to one
+functional change, as is required for:
+
+* git revert
+
+* git cherry-pick
+
+* link:https://www.kernel.org/pub/software/scm/git/docs/git-bisect-lk2009.html[git bisect]
+
+
+[[amending]]
+== Amending commits
+
+Git provides a mechanism to continually update a commit until it’s perfect: use
+`git commit --amend` to remake (re-record) a code change. After you update a
+commit in this way, your branch then points to the new commit. However, the
+older (imperfect) revision is not lost. It can be found via the `git reflog`.
+
+
+[[review]]
+== Code review
+
+At least two well-known open source projects insist on these practices:
+
+* link:http://git-scm.com/[Git]
+* link:http://www.kernel.org/category/about.html/[Linux Kernel]
+
+However, contributors to these projects don’t refine and polish their changes
+in private until they’re perfect. Instead, polishing code is part of a review
+process — the contributor offers their change to the project for other
+developers to evaluate and critique. This process is called _code review_ and
+results in numerous benefits:
+
+* Code reviews mean that every change effectively has shared authorship
+
+* Developers share knowledge in two directions: Reviewers learn from the patch
+author how the new code they will have to maintain works, and the patch
+author learns from reviewers about best practices used in the project.
+
+* Code review encourages more people to read the code contained in a given
+change. As a result, there are more opportunities to find bugs and suggest
+improvements.
+
+* The more people who read the code, the more bugs can be identified. Since
+code review occurs before code is submitted, bugs are squashed during the
+earliest stage of the software development lifecycle.
+
+* The review process provides a mechanism to enforce team and company policies.
+For example, _all tests shall pass on all platforms_ or _at least two people
+shall sign off on code in production_.
+
+Many successful software companies, including Google, use code review as a
+standard, integral stage in the software development process.
+
+
+[[web]]
+== Web-based code review
+
+To review work, the Git and Linux Kernel projects send patches via email.
+
+Code Review (Gerrit) adds a modern web interface to this workflow. Rather than
+send patches and comments via email, Gerrit users push commits to Gerrit where
+diffs are displayed on a web page. Reviewers can post comments directly on the
+diff. If a change must be reworked, users can push a new, amended revision of
+the same change. Reviewers can then check if the new revision addresses the
+original concerns. If not, the process is repeated.
+
+
+[[magic]]
+== Gerrit’s magic
+
+When you push a change to Gerrit, how does Gerrit detect that the commit amends
+a previous change? Gerrit can’t use the SHA-1, since that value changes when
+`git commit --amend` is called. Fortunately, upon amending a commit, the commit
+message is retained by default.
+
+This is where Gerrit's solution lies: Gerrit identifies a conceptual change
+with a footer in the commit message. Each commit message footer contains a
+Change-Id message hook, which uniquely identifies a change across all its
+drafts. For example:
+
+  `Change-Id: I9e29f5469142cc7fce9e90b0b09f5d2186ff0990`
+
+Thus, if the Change-Id remains the same as commits are amended, Gerrit detects
+that each new version refers to the same conceptual change. The Gerrit web
+interface groups versions so that reviewers can see how your change evolves
+during the code review.
+
+To Gerrit, the identifier can be random.
+
+Gerrit provides a client-side link:cmd-hook-commit-msg.html[message hook] to
+automatically add to commit messages when necessary.
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index f5042a7..662e0b3 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -548,6 +548,9 @@
 ----
 Alternatively, click *Ready* from the Change screen.
 
+Only change owners, project owners and site administrators can mark changes as
+`work-in-progress` and `ready`.
+
 [[wip-polygerrit]]
 In the new PolyGerrit UI, you can mark a change as WIP, by selecting *WIP* from
 the *More* menu. The Change screen updates with a yellow header, indicating that
@@ -824,6 +827,12 @@
 and download commands. Note that this option is only shown if the Flash plugin
 is available and the JavaScript Clipboard API is unavailable.
 
+- [[work-in-progress-by-default]]`Set new changes work-in-progress`:
++
+Whether new changes are uploaded as work-in-progress per default. This
+preference just sets the default; the behavior can still be overridden using a
+link:user-upload.html#wip[push option].
+
 [[my-menu]]
 In addition it is possible to customize the menu entries of the `My`
 menu. This can be used to make the navigation to frequently used
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 2df5971..96b5107 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -175,7 +175,11 @@
   and link:rest-api-changes.html#revision-info[RevisionInfo] are
   passed as arguments. Similar to a form submit validation, the
   function must return true to allow the operation to continue, or
-  false to prevent it.
+  false to prevent it. The function may be called multiple times, for
+  example, if submitting a change shows a confirmation dialog, this
+  event may be called to validate that the check whether dialog can be
+  shown, and called again when the submit is confirmed to check whether
+  the actual submission action can proceed.
 
 * `comment`: Invoked when a DOM element that represents a comment is
   created. This DOM element is passed as argument. This DOM element
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 3b8a8cb..dc82ad1 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -29,6 +29,8 @@
 commitMessage:: The full commit message for the change's current patch
 set.
 
+hashtags:: List of hashtags associated with this change.
+
 createdOn:: Time in seconds since the UNIX epoch when this change
 was created.
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 2464c3a..bfebc6a 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -18,7 +18,8 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment 1.8 (or higher).
+. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
+    9 or newer yet.
 
 == Download Gerrit
 
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index b77a66b..ad613a5 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -124,3 +124,20 @@
 
 === header-title
 This endpoint wraps the title-text in the application header.
+
+=== confirm-submit-change
+This endpoint is inside the confirm submit dialog. By default it displays a
+generic confirmation message regarding submission of the change. Plugins may add
+content to this message or replace it entirely.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+The change beinng potentially submitted, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `action`
++
+The submit action, including the title and label, an instance of
+link:rest-api-changes.html#action-info[ActionInfo]
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 03aaabf..4b50961 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -14,7 +14,11 @@
 == DESCRIPTION
 Converts the local username for every account to lower case. The
 local username is the username that is used to login into the Gerrit
-Web UI.
+Web UI. The local username is stored a external ID with scheme
+`gerrit`.
+
+[IMPORTANT]
+This program does not modify usernames in the `username` scheme.
 
 This task is only intended to be run if the configuration parameter
 link:config-gerrit.html#ldap.localUsernameToLowerCase[ldap.localUsernameToLowerCase]
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 4e3c428..19ed98a 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -796,7 +796,7 @@
 
 === Example 10: Combine examples 8 and 9
 In this example we want to both remove the verified and have the four eyes
-principle.  This means we want a combination of examples 7 and 8.
+principle.  This means we want a combination of examples 8 and 9.
 
 `rules.pl`
 [source,prolog]
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 65a15ca..c2a7d21 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -263,6 +263,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true,
       "groups": {
          "53a4f647a89ea57992571187d8025f830625192a": {
@@ -313,6 +314,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true
     }
   }
@@ -399,6 +401,8 @@
 Whether the calling user can upload to any ref.
 |`can_add`            |not set if `false`|
 Whether the calling user can add any ref.
+|`can_add_tags`       |not set if `false`|
+Whether the calling user can add any tag ref.
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 025b29d..de5f278 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -418,7 +418,10 @@
 .Response
 ----
   HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
 
+  )]}'
   ok
 ----
 
@@ -1255,6 +1258,7 @@
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1361,6 +1365,7 @@
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
     "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "mute_common_path_prefixes": true,
     "my": [
       {
@@ -1797,6 +1802,69 @@
   ]
 ----
 
+[delete-draft-comments]
+=== Delete Draft Comments
+--
+'POST /accounts/link:#account-id[\{account-id\}]/drafts:delete'
+--
+
+Deletes some or all of a user's draft comments. The set of comments to delete is
+specified as a link:#delete-draft-comments-input[DeleteDraftCommentsInput]
+entity. An empty input entity deletes all comments.
+
+Only drafts belonging to the caller may be deleted.
+
+.Request
+----
+  POST /accounts/self/drafts.delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "query": "is:abandoned"
+  }
+----
+
+As a response, a list of
+link:#deleted-draft-comment-info[DeletedDraftCommentInfo] entities is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+   )]}'
+   [
+     {
+       "change": {
+         "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+         "project": "myProject",
+         "branch": "master",
+         "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+         "subject": "Implementing Feature X",
+         "status": "ABANDONED",
+         "created": "2013-02-01 09:59:32.126000000",
+         "updated": "2013-02-21 11:16:36.775000000",
+         "insertions": 34,
+         "deletions": 101,
+         "_number": 3965,
+         "owner": {
+           "name": "John Doe"
+         }
+       },
+       "deleted": [
+         {
+           "id": "TvcXrmjM",
+           "path": "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java",
+           "line": 23,
+           "message": "[nit] trailing whitespace",
+           "updated": "2013-02-26 15:40:43.986000000"
+         }
+       ]
+     }
+   ]
+----
+
 [[sign-contributor-agreement]]
 === Sign Contributor Agreement
 --
@@ -2308,6 +2376,37 @@
 |`name`                     |The name of the agreement.
 |=================================
 
+[[delete-draft-comments-input]]
+=== DeleteDraftCommentsInput
+The `DeleteDraftCommentsInput` entity contains information specifying a set of
+draft comments that should be deleted.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name                 ||Description
+|`query`                    |optional|
+A link:user-search.html[change query] limiting results to changes matching this
+query; `has:draft` is implied and not necessary to list explicitly. If not set,
+matches all changes with drafts.
+|=================================
+
+[[deleted-draft-comment-info]]
+=== DeletedDraftCommentInfo
+The `DeletedDraftCommentInfo` entity contains information about draft comments
+that were deleted.
+
+[options="header",cols="1,6"]
+|=================================
+|Field Name                 |Description
+|`change`                   |
+link:rest-api-changes.html#change-info[ChangeInfo] entity describing the change
+on which one or more comments was deleted. Populated with only the
+link:rest-api-changes.html#skip_mergeable[SKIP_MERGEABLE] option.
+|`deleted`                  |
+List of link:rest-api-changes.html#comment-info[CommentInfo] entities for each
+comment that was deleted.
+|=================================
+
 [[diff-preferences-info]]
 === DiffPreferencesInfo
 The `DiffPreferencesInfo` entity contains information about the diff
@@ -2654,6 +2753,9 @@
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
+|`work_in_progress_by_default`  |not set if `false`|
+Whether to link:user-upload.html#wip[set work-in-progress] on
+push or on create changes online by default.
 |============================================
 
 [[preferences-input]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8d1b2d8..c144a09 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -889,7 +889,8 @@
 Sets the topic of a change.
 
 The new topic must be provided in the request body inside a
-link:#topic-input[TopicInput] entity.
+link:#topic-input[TopicInput] entity. Any leading or trailing whitespace
+in the topic name will be removed.
 
 .Request
 ----
@@ -923,10 +924,6 @@
 
 Deletes the topic of a change.
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify a commit message, use
-link:#set-topic[PUT] to delete the topic.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/topic HTTP/1.0
@@ -2145,7 +2142,8 @@
 'POST /changes/link:#change-id[\{change-id\}]/wip'
 --
 
-Marks the change as not ready for review yet.
+Marks the change as not ready for review yet. Changes may only be marked not
+ready by the owner, project owners or site administrators.
 
 The request body does not need to include a
 link:#work-in-progress-input[WorkInProgressInput] entity if no review comment
@@ -2173,7 +2171,8 @@
 'POST /changes/link:#change-id[\{change-id\}]/ready'
 --
 
-Marks the change as ready for review (set WIP property to false).
+Marks the change as ready for review (set WIP property to false). Changes may
+only be marked ready by the owner, project owners or site administrators.
 
 Activates notifications of reviewer. The request body does not need
 to include a link:#work-in-progress-input[WorkInProgressInput] entity
@@ -2233,17 +2232,9 @@
 Marks the change to be non-private. Note users can only unmark own private
 changes.
 
-A message can be specified in the request body inside a
-link:#private-input[PrivateInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "message": "This is a security fix that must not be public."
-  }
 ----
 
 .Response
@@ -2253,9 +2244,11 @@
 
 If the change was already not private, the response is "`409 Conflict`".
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to set a message options, use a
-POST request:
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity. Historically, this method allowed
+a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -2274,7 +2267,9 @@
 --
 
 Marks a change as ignored. The change will not be shown in the incoming
-reviews dashboard, and email notifications will be suppressed.
+reviews dashboard, and email notifications will be suppressed. Ignoring
+a change does not cause the change's "updated" timestamp to be modified,
+and the owner is not notified.
 
 .Request
 ----
@@ -2480,7 +2475,66 @@
       "username": "jdoe"
      },
      "date": "2013-03-23 21:34:02.419000000",
-     "message": "Change message removed by: Administrator; Reason: spam",
+     "message": "a change message",
+     "_revision_number": 1
+  }
+----
+
+[[delete-change-message]]
+=== Delete Change Message
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/message/link:#change-message-id[\{change-message-id\}' +
+'POST /changes/link:#change-id[\{change-id\}]//message/link:#change-message-id[\{change-message-id\}/delete'
+--
+
+Deletes a change message by replacing the change message with a new message,
+which contains the name of the user who deleted the change message and the
+reason why it was deleted. The reason can be provided in the request body as a
+link:#delete-change-message-input[DeleteChangeMessageInput] entity.
+
+Note that only users with the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability are permitted to delete a change message.
+
+To delete a change message, send a DELETE request:
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message/aaee04dcb46bafc8be24d8aa70b3b1beb7df5780 HTTP/1.0
+----
+
+To provide a reason for the deletion, use a POST request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message/aaee04dcb46bafc8be24d8aa70b3b1beb7df5780/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "spam"
+  }
+----
+
+As response a link:#change-message-info[ChangeMessageInfo] entity is returned that
+describes the updated change message.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "aaee04dcb46bafc8be24d8aa70b3b1beb7df5780",
+    "author": {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "username": "jdoe"
+     },
+     "date": "2013-03-23 21:34:02.419000000",
+     "message": "Change message removed by: Administrator\nReason: spam",
      "_revision_number": 1
   }
 ----
@@ -3127,18 +3181,17 @@
 
 Deletes a reviewer from a change.
 
-Options can be provided in the request body as a
-link:#delete-reviewer-input[DeleteReviewerInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-reviewer-input[DeleteReviewerInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -3208,18 +3261,17 @@
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
-Options can be provided in the request body as a
-link:#delete-vote-input[DeleteVoteInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -4582,17 +4634,17 @@
 Deletes a published comment of a revision. Instead of deleting the
 whole comment, this endpoint just replaces the comment's message
 with a new message, which contains the name of the user who deletes
-the comment and the reason why it's deleted. The reason can be
-provided in the request body as a
-link:#delete-comment-input[DeleteCommentInput] entity.
+the comment and the reason why it's deleted.
 
 Note that only users with the
 link:access-control.html#capability_administrateServer[Administrate Server]
 global capability are permitted to delete a comment.
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a
-POST request:
+Deletion reason can be provided in the request body as a
+link:#delete-comment-input[DeleteCommentInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -4803,8 +4855,8 @@
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
 ----
 
-As result a map is returned that maps the link:#file-id[file path] to a list of
-link:#file-info[FileInfo] entries. The entries in the map are
+As result a map is returned that maps the link:#file-id[file path] to a
+link:#file-info[FileInfo] entry. The entries in the map are
 sorted by file path.
 
 .Response
@@ -5392,18 +5444,17 @@
 Note, that even when the last vote of a reviewer is removed the reviewer itself
 is still listed on the change.
 
-Options can be provided in the request body as a
-link:#delete-vote-input[DeleteVoteInput] entity.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
 ----
 
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify options, use a POST
-request:
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use a POST request instead:
 
 .Request
 ----
@@ -5597,8 +5648,9 @@
 The time and date describing when the approval was made.
 |`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
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`post_submit` |not set if `false`|
 If true, this vote was made after the change was submitted.
@@ -5861,8 +5913,9 @@
 |`message`            ||The text left by the user.
 |`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
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`_revision_number`    |optional|
 Which patchset (if any) generated this message.
@@ -5982,7 +6035,8 @@
 |`tag`         |optional, drafts only|
 Value of the `tag` field. Only allowed on link:#create-draft[draft comment] +
 inputs; for published comments, use the `tag` field in +
-link#review-input[ReviewInput]
+link#review-input[ReviewInput]. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. This value will
 default to false if the comment is an orphan, or the value of the `in_reply_to`
@@ -5993,13 +6047,22 @@
 === CommentRange
 The `CommentRange` entity describes the range of an inline comment.
 
+The comment range is a range from the start position, specified by `start_line`
+and `start_character`, to the end position, specified by `end_line` and
+`end_character`. The start position is *inclusive* and the end position is
+*exclusive*.
+
+So, a range over part of a line will have `start_line` equal to
+`end_line`; however a range with `end_line` set to 5 and `end_character` equal
+to 0 will not include any characters on line 5,
+
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`start_line`        ||The start line number of the range. (1-based, inclusive)
-|`start_character`   ||The character position in the start line. (0-based, inclusive)
-|`end_line`          ||The end line number of the range. (1-based, exclusive)
-|`end_character`     ||The character position in the end line. (0-based, exclusive)
+|Field Name          ||Description
+|`start_line`        ||The start line number of the range. (1-based)
+|`start_character`   ||The character position in the start line. (0-based)
+|`end_line`          ||The end line number of the range. (1-based)
+|`end_character`     ||The character position in the end line. (0-based)
 |===========================
 
 [[commit-info]]
@@ -6048,6 +6111,20 @@
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[delete-change-message-input]]
+=== DeleteChangeMessageInput
+The `DeleteChangeMessageInput` entity contains the options for deleting a change message.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name               ||Description
+|`reason`                 |optional|
+The reason why the change message should be deleted. +
+If set, the change message will be replaced with
+"Change message removed by: `name`\nReason: `reason`",
+or just "Change message removed by: `name`." if not set.
+|=============================
+
 [[delete-comment-input]]
 === DeleteCommentInput
 The `DeleteCommentInput` entity contains the option for deleting a comment.
@@ -6279,10 +6356,14 @@
 Only set if the file was renamed or copied.
 |`lines_inserted`|optional|
 Number of inserted lines. +
-Not set for binary files or if no lines were inserted.
+Not set for binary files or if no lines were inserted. +
+An empty last line is not included in the count and hence this number can
+differ by one from details provided in <<#diff-info,DiffInfo>>.
 |`lines_deleted` |optional|
 Number of deleted lines. +
-Not set for binary files or if no lines were deleted.
+Not set for binary files or if no lines were deleted. +
+An empty last line is not included in the count and hence this number can
+differ by one from details provided in <<#diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
 |`size`          ||
@@ -6787,8 +6868,8 @@
 |`tag`                    |optional|
 Apply this tag to the review comment message, votes, and inline
 comments. Tags may be used by CI or other automated systems to
-distinguish them from human reviews. Comments with specific tag
-values can be filtered out in the web UI.
+distinguish them from human reviews. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
 |`labels`                 |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 4b8922a..3ec989e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1927,7 +1927,7 @@
 GerritInfo] entity.
 |`note_db_enabled`         |not set if `false`|
 Whether the NoteDb storage backend is fully enabled.
-|`plugin `                 ||
+|`plugin`                  ||
 Information about Gerrit extensions by plugins as
 link:#plugin-config-info[PluginConfigInfo] entity.
 |`receive`                 |optional|
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index bc5a3c6..b517d3c 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -597,14 +597,6 @@
 
 Deletes the description of a project.
 
-The request body does not need to include a
-link:#project-description-input[ProjectDescriptionInput] entity if no
-commit message is specified.
-
-Please note that some proxies prohibit request bodies for DELETE
-requests. In this case, if you want to specify a commit message, use
-link:#set-project-description[PUT] to delete the description.
-
 .Request
 ----
   DELETE /projects/plugins%2Freplication/description HTTP/1.0
@@ -615,6 +607,27 @@
   HTTP/1.1 204 No Content
 ----
 
+A commit message can be provided in the request body as a
+link:#project-description-input[ProjectDescriptionInput] entity.
+Historically, this method allowed a body in the DELETE, but that behavior is
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+In this case, use link:#set-project-description[PUT] instead.
+
+.Request
+----
+  PUT /projects/plugins%2Freplication/description HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update the project description"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-project-parent]]
 === Get Project Parent
 --
@@ -1128,6 +1141,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
@@ -1229,6 +1243,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "global:Anonymous-Users": {
@@ -1354,6 +1369,32 @@
 
 
 [[index]]
+=== Index project
+
+Adds or updates the current project (and children, if specified) in the secondary index.
+The indexing task is executed asynchronously in background and this command returns
+immediately if `async` is specified in the input.
+
+As an input, a link:#index-project-input[IndexProjectInput] entity can be provided.
+
+.Request
+----
+  POST /projects/MyProject/index HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "index_children": "true"
+    "async": "true"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+----
+
+[[index.changes]]
 === Index all changes in a project
 
 Adds or updates all the changes belonging to a project in the secondary index.
@@ -1362,7 +1403,7 @@
 
 .Request
 ----
-  POST /projects/MyProject/index HTTP/1.0
+  POST /projects/MyProject/index.changes HTTP/1.0
 ----
 
 .Response
@@ -1371,6 +1412,95 @@
   Content-Disposition: attachment
 ----
 
+[[check]]
+=== Check project consistency
+
+Performs consistency checks on the project.
+
+Which consistency checks should be performed is controlled by the
+link:#check-project-input[CheckProjectInput] entity in the request
+body.
+
+The following consistency checks are supported:
+
+[[auto-closeable-changes-check]]
+--
+* AutoCloseableChangesCheck: Searches for open changes that can be
+  auto-closed because a patch set of the change is already contained in
+  the destination branch or because the destination branch contains a
+  commit with the same Change-Id. Normally Gerrit auto-closes such
+  changes when the corresponding commits are pushed directly to the
+  repository. However if a branch is updated behind Gerrit's back or if
+  auto-closing changes fails (and the push is still successful) change
+  states can get inconsistent (changes that are already part of the
+  destination branch are still open). This consistency check is
+  intended to detect and repair this situation.
+--
+
+To fix any problems that can be fixed automatically set the `fix` field
+in the inputs for the consistency checks  to `true`.
+
+This REST endpoint requires the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability.
+
+.Request
+----
+  POST /projects/MyProject/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "auto_closeable_changes_check": {
+      "fix": true,
+      "branch": "refs/heads/master",
+      "max_commits": 100
+    }
+  }
+----
+
+As response a link:#check-project-result-info[CheckProjectResultInfo]
+entity is returned that results for the consistency checks.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "auto_closeable_changes_check_result": {
+      "auto_closeable_changes": {
+        "refs/heads/master": [
+          {
+            "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "project": "myProject",
+            "branch": "master",
+            "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "subject": "Implementing Feature X",
+            "status": "NEW",
+            "created": "2013-02-01 09:59:32.126000000",
+            "updated": "2013-02-21 11:16:36.775000000",
+            "insertions": 34,
+            "deletions": 101,
+            "_number": 3965,
+            "owner": {
+              "name": "John Doe"
+            },
+            "problems": [
+              {
+                "message": "Patch set 1 (2f15e416237ed9b561199f24184f5f5d2708c584) is merged into destination ref refs/heads/master (2f15e416237ed9b561199f24184f5f5d2708c584), but change status is NEW",
+                "status": "FIXED",
+                "outcome": "Marked change as merged"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2486,6 +2616,51 @@
   }
 ----
 
+[[list-files]]
+=== List Files
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/files/'
+--
+
+Lists the files that were modified, added or deleted in a commit.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/files/ HTTP/1.0
+----
+
+As result a map is returned that maps the link:rest-api-changes.html#file-id[file path] to a
+link:rest-api-changes.html#file-info[FileInfo] entry. The entries in the map are
+sorted by file path.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "/COMMIT_MSG": {
+      "status": "A",
+      "lines_inserted": 7,
+      "size_delta": 551,
+      "size": 551
+    },
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": {
+      "lines_inserted": 5,
+      "lines_deleted": 3,
+      "size_delta": 98,
+      "size": 23348
+    }
+  }
+----
+
+The integer-valued request parameter `parent` changes the response to return a
+list of the files which are different in this commit compared to the given
+parent commit. This is useful for supporting review of merge commits. The value
+is the 1-based index of the parent's position in the commit object.
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
@@ -2766,6 +2941,52 @@
 check. This defaults to `read`. If given, it `ref` must be given too.
 |=========================================
 
+[[auto_closeable_changes_check_input]]
+=== AutoCloseableChangesCheckInput
+The `AutoCloseableChangesCheckInput` entity contains options for running
+the link:#auto-closeable-changes-check[AutoCloseableChangesCheck].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`fix`           |optional|
+Whether auto-closeable changes should be closed automatically.
+|`branch`        ||
+The branch for which the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] should be performed. The 'refs/heads/'
+prefix for the branch name can be omitted.
+|`skip_commits`  |optional|
+Number of commits that should be skipped when walking the commits of
+the branch.
+|`max_commits`   |optional|
+Maximum number of commits to walk. If not specified this defaults to
+10,000 commits. 10,000 is also the maximum that can be set.
+Auto-closing changes is an expensive operation and the more commits
+are walked the slower it gets. This is why you should avoid walking too
+many commits.
+|=============================
+
+[[auto_closeable_changes_check_result]]
+=== AutoCloseableChangesCheckResult
+The `AutoCloseableChangesCheckResult` entity contains the results of
+running the link:#auto-closeable-changes-check[AutoCloseableChangesCheck]
+on a project.
+
+[options="header",cols="1,6"]
+|====================================
+|Field Name              |Description
+|`auto_closeable_changes`|
+Changes that can be auto-closed as list of
+link:rest-api-changes.html#change-info[ChangeInfo] entities. For each
+returned link:rest-api-changes.html#change-info[ChangeInfo] entity the
+`problems` field is populated that includes details about the detected
+issues. If `fix` in the link:#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] was set to `true`, `status` and
+`outcome` in link:rest-api-changes.html#problem-info[ProblemInfo] are
+populated. If the status says `FIXED` Gerrit was able to auto-close the
+change now.
+|====================================
+
 [[ban-input]]
 === BanInput
 The `BanInput` entity contains information for banning commits in a
@@ -2823,6 +3044,36 @@
 If not set, `HEAD` will be used as base revision.
 |=======================
 
+[[check-project-input]]
+=== CheckProjectInput
+The `CheckProjectInput` entity contains information about which
+consistency checks should be run on a project.
+
+[options="header",cols="1,^2,4"]
+|===========================================
+|Field Name                    ||Description
+|`auto_closeable_changes_check`|optional|
+Parameters for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] entity.
+|===========================================
+
+[[check-project-result-info]]
+=== CheckProjectResultInfo
+The `CheckProjectResultInfo` entity contains results for consistency
+checks that have been run on a project.
+
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name                           ||Description
+|`auto_closeable_changes_check_result`|optional|
+Results for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_result[
+AutoCloseableChangesCheckResult] entity.
+|==================================================
+
 [[config-info]]
 === ConfigInfo
 The `ConfigInfo` entity contains information about the effective project
@@ -2854,7 +3105,8 @@
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether a
 valid link:user-changeid.html[Change-Id] footer in any commit uploaded
 for review is required. This does not apply to commits pushed directly
-to a branch or tag.
+to a branch or tag. This property is deprecated and will be removed in
+a future release.
 |`enable_signed_push`|optional, not set if signed push is disabled|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 signed push validation is enabled on the project.
@@ -2864,10 +3116,13 @@
 |`reject_implicit_merges`|optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 implicit merges should be rejected on changes pushed to the project.
-|`private_by_default`     ||
+|`private_by_default`         ||
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 all new changes are set as private by default.
-|`max_object_size_limit`     ||
+|`work_in_progress_by_default`||
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+all new changes are set as work-in-progress by default.
+|`max_object_size_limit`      ||
 The link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] of this project as a link:#max-object-size-limit-info[
 MaxObjectSizeLimitInfo] entity.
@@ -2943,6 +3198,8 @@
 directly to a branch or tag. +
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
+This property is deprecated and will be removed in
+a future release.
 |`reject_implicit_merges`                  |optional|
 Whether a check for implicit merges will be performed when changes
 are pushed for review. +
@@ -3128,6 +3385,19 @@
 omitted.
 |============================
 
+[[index-project-input]]
+=== IndexProjectInput
+The `IndexProjectInput` contains parameters for indexing a project.
+
+[options="header",cols="1,^2,4"]
+|================================
+|Field Name         ||Description
+|`index_children`   ||
+If children should be indexed recursively.
+|`async`            ||
+If projects should be indexed asynchronously.
+|================================
+
 [[inherited-boolean-info]]
 === InheritedBooleanInfo
 A boolean value that can also be inherited.
@@ -3167,16 +3437,17 @@
 |===============================
 |Field Name        ||Description
 |`value`           |optional|
-The effective value of the max object size limit as a formatted string. +
+The effective value in bytes of the max object size limit. +
 Not set if there is no limit for the object size.
 |`configured_value`|optional|
 The max object size limit that is configured on the project as a
 formatted string. +
 Not set if there is no limit for the object size configured on project
 level.
-|`inherited_value` |optional|
-The max object size limit that is inherited as a formatted string. +
-Not set if there is no global limit for the object size.
+|`summary`         |optional|
+A string describing whether the value was inherited or overridden from
+the parent project or global config. +
+Not set if not inherited or overridden.
 |===============================
 
 [[project-access-input]]
@@ -3297,6 +3568,8 @@
 |`require_change_id`                           |`INHERIT` if not set|
 Whether the usage of Change-Ids is required for the project (`TRUE`,
 `FALSE`, `INHERIT`).
+This property is deprecated and will be removed in
+a future release.
 |`max_object_size_limit`     |optional|
 Max allowed Git object size for this project.
 Common unit suffixes of 'k', 'm', or 'g' are supported.
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 0957d32..8f6a47b 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -191,6 +191,53 @@
 "`422 Unprocessable Entity`" is returned if the ID of a resource that is
 specified in the request body cannot be resolved.
 
+[[tracing]]
+=== Request Tracing
+For each REST endpoint tracing can be enabled by setting the
+`trace=<trace-id>` request parameter. It is recommended to use the ID
+of the issue that is being investigated as trace ID.
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?trace=issue/123&q=J
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?trace&q=J
+----
+
+Alternatively request tracing can also be enabled by setting the
+`X-Gerrit-Trace` header:
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?q=J
+  X-Gerrit-Trace: issue/123
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. The trace ID is returned with
+the REST response in the `X-Gerrit-Trace` header.
+
+.Example Response
+----
+HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+  X-Gerrit-Trace: 1533885943749-8257c498
+
+  )]}'
+  ... <json> ...
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index bce8183..ada2560 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -1,191 +1,236 @@
-= Inline Edit
+= Creating and Editing Changes in the Gerrit Web Interface
 
-This page explains the workflow for creating and amending changes in the
-browser.
+== Overview
+
+The following content explains how to use the Gerrit web interface to create
+and edit changes. Use the web interface to make minor changes to files. When
+you create a change in the Gerrit user interface, you don't clone a Gerrit
+repository or use the CLI to issue Git commands — you perform your work
+directly in the Gerrit web interface.
+
+To learn more, see the link:intro-user.html[Gerrit User's Guide].
 
 
 [[create-change]]
-== Creating a New Change
+== Creating a Change
 
-A new change can be created directly in the browser, meaning it is not necessary
-to clone the whole repository to make trivial changes.
+To create a change in the Gerrit web interface:
 
-The new change is created as a public
-link:user-upload.html#wip[work-in-progress change].
+. From the link:http://gerrit-review.googlesource.com[Gerrit Code Review]
+  dashboard, select Browse > Repositories.
 
-There are two different ways to create a new change:
+. Under Repository Name, click the name of the repository you want to work
+  on. For example, Public-Projects. To find a specific repository, enter all
+  or part of its name next to Filter:
++
+image::images/inline-edit-home-page.png[width=600]
 
-By clicking on the 'Create Change' button in the project screen:
+. In the left navigation panel for the repository you selected, click
+  Commands:
++
+image::images/inline-edit-create-change.png[width=350]
 
-[[create-change-from-project-info-screen]]
+. Under Repository Commands, click Create Change.
 
-image::images/inline-edit-create-change-project-screen.png[width=800, link="images/inline-edit-create-change-project-screen.png"]
+. In the Create Change window, enter the following information:
 
-The user can select the branch on which the new change should be created:
+   *  Select branch for new change: Specify the destination branch of the
+      change.
 
-image::images/inline-edit-create-change-project-screen-dialog.png[width=800, link="images/inline-edit-create-change-project-screen-dialog.png"]
+   *  Provide base commit SHA1 for change: Leave this field blank.
 
-By clicking the 'Follow-Up' button on the change screen, to create a new change
-based on the selected change.
++
+IMPORTANT: Git uses a unique SHA1 value to identify each and every commit (in
+other words, each Git commit generates a new SHA1 hash). This value differs
+from a Gerrit Change-Id, which is used by Gerrit to uniquely identify a
+change. The Gerrit Change-Id remains static throughout the life of a Gerrit
+change.
 
-[[create-change-from-change-screen]]
+   -  Description: Briefly describe the change. Be sure to use the
+      link:dev-contributing.html#commit-message[Commit Message] format.
+      The first line becomes the subject of the change and is included in
+      the Commit Message. Because the message also appears on its own in
+      dashboards and in the results of `git log --pretty=oneline output`,
+      make the message informative and brief.
 
-image::images/inline-edit-create-follow-up-change.png[width=800, link="images/inline-edit-create-follow-up-change.png"]
+   -  Private change: Select this option to designate this change as private.
+      Only you (and any reviewers you add) can see your private changes.
+
+. On the Create Change window, click Create. Gerrit creates a public Work
+  In Progress (WIP) change. Until the change is sent for review, it remains a
+  WIP and appears in _your_ dashboard only. In addition, all email
+  notifications are turned off.
+
+. Add the files you want to be reviewed.
+
+
+[[add-files]]
+== Adding a File to a Change
+
+Files can only be added to changes that have not been merged into the code
+base.
+
+To add a file to the change:
+
+. In the top left corner of the change, click Edit.
+. Next to Files, click Open:
+
++
+image::images/inline-edit-open-file.png[width=600]
+
+. In the Open File window, do one of the following:
+
+* To add an existing file:
+
+ ** Enter all or part of the file name in the text box. Gerrit automatically
+    populates a list of possible matching files:
++
+image::images/inline-edit-prefill-files.png[width=500]
++
+ ** Select the file you want to add to the change.
+ ** Click Open.
++
+_or,_
+
+*  To create a new file, enter the name of the new file you want to add to the
+change and then click Open.
+
 
 [[editing-change]]
-== Editing Changes
+== Modifying a Change
 
-To switch to edit mode, press the 'Edit' button at the top of the file list:
+To work on a file you've added to a change:
 
-[[switch-to-edit-mode]]
-image::images/inline-edit-enter-edit-mode-from-file-list.png[width=800, link="images/inline-edit-enter-edit-mode-from-file-list.png"]
+. On the change page, click the file name. When you add a new file to a
+  change, a blank page is displayed. When you add an existing file to a
+  change, the entire file is displayed.
 
-While in edit mode, it is possible to add new files to the change by clicking
-the 'Add...' button at the top of the file list.
+. Update the file and then click Save. You _must_ click Save to add the
+  file to the change.
 
-File changes can be reverted or files can be removed from the change or
-deleted files can be restored, by clicking the icons to the left of the file
-name.
+. To close the text editor and display the change page, click Close.
++
+When you save your work and close the file, the file is added to the change
+and the file name is listed in the Files section. The letter displayed to the
+left of the file name denotes the action performed on the file. In this case,
+one file was modified:
 
-To switch from edit mode back to review mode, click the 'Done Editing' button.
+-  M: Modified
+-  A: Added
+-  D: Deleted
++
+image::images/inline-edit-add-file-page.png[width=650]
 
-image::images/inline-edit-file-list-in-edit-mode.png[width=800, link="images/inline-edit-file-list-in-edit-mode.png"]
+. When you're done editing and adding files, click Stop Editing.
 
-[[open-full-screen-editor]]
-While in edit mode, clicking on a file name in the file list opens a full
-screen editor for that file.
+. Click Publish Edit. When you publish an edit, you promote it to a regular
+  patch set. The special ref that represents the change is deleted when the
+  change is published.
 
-To save edits, click the 'Save' button or press `CTRL-S`.  To return to the
-change screen, click the 'Close' button.
+Not happy with your edits? Click Delete Edit.
 
-Note that when editing the commit message, trailing blank lines will be stripped.
 
-image::images/inline-edit-full-screen-editor.png[width=800, link="images/inline-edit-full-screen-editor.png"]
+[[submit-change]]
+== Starting the Review
 
-If there are unsaved edits when the 'Close' button is pressed, a dialog will
-pop up asking to confirm the edits.
+When you start a review, Gerrit removes the WIP designation and submits
+the change to code review. The change appears in other Gerrit dashboards and
+reviewers are notified when the change is updated.
 
-image::images/inline-edit-confirm-unsaved-edits.png[width=800, link="images/inline-edit-confirm-unsaved-edits.png"]
+To start a review:
 
-To discard the unsaved edits and return to the change screen, click the 'OK'
-button. To continue editing, click 'Cancel'.
+. Open the change and then click Start Review:
++
+image::images/inline-edit-start-review-button.png[width=400]
 
-[[switch-to-edit-mode-from-side-by-side]]
+. In the change notification form:
 
-While in review mode, it is possible to switch directly to edit mode and into an
-editor for a file under review by clicking on the edit icon in the patch set list
-on the side-by-side diff view.
+ ** Add the names of the reviewers and anyone else you want to copy.
+ ** Describe the change.
+ ** Click Start Review:
++
+image::images/inline-edit-review-message.png[width=550]
 
-image::images/inline-edit-enter-edit-mode-from-diff.png[width=800, link="images/inline-edit-enter-edit-mode-from-diff.png"]
+The change is now displayed in other Gerrit dashboards and reviewers are
+notified that the change is available for code review.
 
-[[reviewing-changes-made-in-change-edit]]
-== Reviewing Change Edits
 
-Change edits are reviewed in the same way as regular patch sets, using the
-side-by-side diff screen. Change edits are shown as 'edit' in the patch list
-on the diff screen:
+[[review-edits]]
+== Reviewing Changes
 
-image::images/inline-edit-edit-in-diff-screen-patch-list.png[width=800, link="images/inline-edit-edit-in-diff-screen-patch-list.png"]
+Use the side-by-side diff screen.
 
-and on the change screen:
+image::images/inline-edit-diff-screen.png[width=800]
 
-image::images/inline-edit-edit-in-patch-list.png[width=800, link="images/inline-edit-edit-in-patch-list.png"]
+It's possible that subsequent patch sets may exist. For example, this sequence
+means that the change was created on top of patch set 9 while a regular
+patchset was uploaded later:
 
-Note that patch sets may exist that were created after the change edit was created.
+1 2 3 4 5 6 7 8 9 edit 10
 
-For example this sequence:
 
-`1 2 3 4 5 6 7 8 9 edit 10`
+[[search-for-changes]]
+== Searching for Changes with Pending Edits
 
-means that the change edit was created on top of patch set number 9 and a regular
-patch set was uploaded later.
+To find changes with pending edits:
 
-[[change-edit-actions]]
-== Change Edit Actions
+*  From the Gerrit dashboard, select Your > Changes. All your changes are
+listed, according to Work in progress, Outgoing reviews, Incoming reviews,
+CCed on, and Recently closed.
 
-Change edits can be deleted, published and rebased, and a patch set that
-represents a change edit can be downloaded like a regular patch set.
+For more information about Search operators, see
+link:user-search.html[Searching Changes]. For example, to find only
+those changes that contain edits, see link:user-search.html#has[has:edit].
 
-[[delete-change-edit]]
 
-There is a special ref for a change edit. When the change edit is deleted, this
-ref is deleted as well. To delete a change edit click on the "Delete Edit"
-button.
+[change-edit-actions]
+== Modifying Changes
 
-[[publish-change-edit]]
-
-When a change edit is based on the current patch set, it can be published. By
-publishing a change edit it is promoted to a regular patch set. The special ref
-that represents the change edit is deleted on publish. To publish a change edit
-click on the "Publish Edit" button. This button is only shown when the change
-edit is based on the current patch set. Otherwise the change edit must first be
-rebased onto the current patch set.
 
 [[rebase-change-edit]]
+=== Rebasing a Change Edit
 
-Only change edits that are based on the current patch set can be published. If
-in the meantime a new patch set was uploaded, the change edit must be rebased on
-top of the current patch set before it can be published. Rebasing a change
-edit is done by clicking on the "Rebase Edit" button. If the rebase results in
-conflicts, these conflicts cannot be resolved in the browser. In this case the
-change edit must be downloaded (see below) and the conflicts must be resolved in
-the local environment. The commit that contains the conflict resolution can then
-be uploaded by setting `edit` as option on the target ref:
+Only when a change is based on the current patch set can the change be
+published. In the meantime, if a new patch set has been uploaded, the change
+must be rebased on top of the current patch set before the change can be
+published.
 
-----
-  $ git push host HEAD:refs/for/master%edit
-----
+To rebase a change:
+
+-  Open the change and then click Rebase Edit.
+
+If the rebase generates conflicts, the conflicts can't be resolved in the web
+interface. Instead, the change must be downloaded (see below) and the conflicts
+resolved in the local environment.
+
+When the conflicts are resolved in the local environment, the commit that
+contains the conflict resolution can be uploaded by setting `edit` as an
+option on the target ref. For example:
+
+....
+$ git push host HEAD:refs/for/master%edit
+....
+
 
 [[download-change-edit-patch]]
+=== Downloading a Patch
 
-Like regular patch sets, change edits can be downloaded by the download
-commands (e.g. provided by the `download-commands` plugin). To download a
-change edit, select the desired scheme from the "Download" dropdown and copy the
-command to your terminal. Note: only change edit owners and users that were
-granted the link:access-control.html#capability_accessDatabase[accessDatabase]
-global capability are able to access change edit refs.
+As with regular patch sets, you can download changes. For example, as provided
+by the `download-commands` plugin. Only the owners of a change and those
+users granted the
+link:access-control.html#capability_accessDatabase[accessDatabase] global
+capability can access change refs.
 
-[[search-for-change-edits]]
+To download a change:
 
-To search change edits from the UI the link:user-search.html#has[has:edit]
-predicate can be used.
+. Open the change, click the More icon, and then select Download patch.
+. Copy the desired scheme from the Download drop-down.
+. Paste the command into a terminal window.
 
-Alternatively change edits can be accessed through "My => Edits" dashboard.
-
-[[not-implemented-features]]
-== Not Implemented Features
-
-* Support default configuration options for inline editor that an
-administrator has set in `refs/users/default:preferences.config` file.
-
-* Allow to rename files that are already contained in the change (from the file table).
-The same rename file dialog can be used with preselected and disabled original file
-name.
-
-* Changed files in change edit should be marked as changed in file table in edit mode.
-One option is to use dirty icon or "*" char in front of changed files, another option
-is to use different hyperlink color for changed files (red?), to avoid adding yet another
-column to the file table
-
-* Add navigation icons in header area of edit screen. When dozen files need to be changed
-in context of change edit, this is not the best workflow to open one file in edit screen,
-change it, save it, close edit screen and select next file from the file table to edit.
-"<-" | "->" icons in header of edit screen could be used to navigate to the next file to
-change from the file table. This would behave like the navigation icons in side by side
-with the following logic on click:
-
-** "save-when-file-was-changed" or
-** "close-when-no-changes"
-
-* Implement conflict resolution during rebase of change edit using inline edit
-feature by creating new edit on top of current patch set with auto merge content
-
-* Similarly, reuse inline edit feature for conflict resolution during rebase of regular
-patch sets
+image::images/inline-edit-actions-download.png[width=600]
 
 GERRIT
-------
+
 Part of link:index.html[Gerrit Code Review]
 
-SEARCHBOX
----------
+SEARCHBOX
\ No newline at end of file
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
new file mode 100644
index 0000000..8430e97
--- /dev/null
+++ b/Documentation/user-request-tracing.txt
@@ -0,0 +1,72 @@
+= Request Tracing
+
+Gerrit supports on-demand tracing of single requests that results in
+additional logs with debug information that are written to the
+`error_log`. The logs that correspond to a traced request are
+associated with a unique trace ID. This trace ID is returned with the
+response and can be used by an administrator to find the matching log
+entries.
+
+How tracing is enabled and how the trace ID is returned depends on the
+request type:
+
+* REST API: For REST calls tracing can be enabled by setting the
+  `trace` request parameter or the `X-Gerrit-Trace` header, the trace
+  ID is returned as `X-Gerrit-Trace` header. More information about
+  this can be found in the link:rest-api.html#tracing[Request Tracing]
+  section of the link:rest-api.html[REST API documentation].
+* SSH API: For SSH calls tracing can be enabled by setting the
+  `--trace` option. More information about this can be found in
+  the link:cmd-index.html#trace[Trace] section of the
+  link:cmd-index.html[SSH command documentation].
+* Git: For Git pushes tracing can be enabled by setting the
+  `trace` push option, the trace ID is returned in the command output.
+  More information about this can be found in
+  the link:user-upload.html#trace[Trace] section of the
+  link:user-upload.html[upload documentation]. Tracing for Git requests
+  other than Git push is not supported.
+
+When request tracing is enabled it is possible to provide an ID that
+should be used as trace ID. If a trace ID is not provided a trace ID is
+automatically generated. The trace ID must be provided to the support
+team so that they can find the trace.
+
+When doing traces it is recommended to specify the ID of the issue
+that is being investigated as trace ID so that the traces of the issue
+can be found more easily. When the issue ID is used as trace ID there
+is no need to find the generated trace ID and report it in the issue.
+
+Since tracing consumes additional server resources tracing should only
+be enabled for single requests if there is a concrete need for
+debugging. In particular bots should never enable tracing for all their
+requests by default.
+
+== Find log entries for a trace ID
+
+If tracing is enabled all log messages that correspond to the traced
+request have a `TRACE_ID` tag set, e.g.:
+
+----
+[2018-08-13 15:28:08,913] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Received REST request: GET /a/accounts/self (parameters: [trace]) [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,914] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Calling user: admin [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,942] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : REST call succeeded: 200 [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+----
+
+By doing a grep with the trace ID over the error log the log entries
+that correspond to the request can be found.
+
+== Which information is captured in a trace?
+
+* request details
+** REST API: request URL, request parameter names, calling user,
+   response code, response body on errors
+** SSH API: parameter names
+** Git API: push options, magic branch parameter names
+* cache misses, cache evictions
+* reads from NoteDb, writes to NoteDb
+* reads of meta data files, writes of meta data files
+* index queries (with parameters and matches)
+* reindex events
+* permission checks (e.g. which rule is responsible for a deny)
+* timer metrics
+* all other logs
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 3804734d..fb94b67 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -258,7 +258,9 @@
 For open or abandoned changes, the `Delete Change` button will be available
 and if the user is the change owner and is granted the
 link:access-control.html#category_delete_own_changes[Delete Own Changes]
-permission or if they are an administrator.
+permission, if they are granted the
+link:access-control.html#category_delete_changes[Delete Changes] permission,
+or if they are an administrator.
 
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
index ba20adb..11c1326 100644
--- a/Documentation/user-search-projects.txt
+++ b/Documentation/user-search-projects.txt
@@ -24,6 +24,11 @@
 Matches projects whose description contains 'DESCRIPTION', using a
 full-text search.
 
+[[state]]
+state:'STATE'::
++
+Matches project's state. Can be either 'active' or 'read-only'.
+
 == Magical Operators
 
 [[is-visible]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index bebe81b..41cb380 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -165,6 +165,25 @@
 Changes occurring in 'PROJECT' or in one of the child projects of
 'PROJECT'.
 
+[[repository]]
+repository:'REPOSITORY'::
++
+Changes occurring in 'REPOSITORY'. If 'REPOSITORY' starts with `^` it
+matches repository names by regular expression.  The
+link:http://www.brics.dk/automaton/[dk.brics.automaton
+library] is used for evaluation of such patterns.
+
+[[repositories]]
+repositories:'PREFIX'::
++
+Changes occurring in repositories starting with 'PREFIX'.
+
+[[parentrepository]]
+parentrepository:'REPOSITORY'::
++
+Changes occurring in 'REPOSITORY' or in one of the child repositories of
+'REPOSITORY'.
+
 [[branch]]
 branch:'BRANCH'::
 +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index c12a38c..751e886 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -306,6 +306,14 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%ready
 ----
 
+Only change owners, project owners and site administrators can specify
+`work-in-progress` and `ready` options on push.
+
+The default for this option can be set as a
+link:intro-user.html#work-in-progress-by-default[user preference]. If the
+preference is set so the default behavior is to create `work-in-progress`
+changes, this can be overridden with the `ready` option.
+
 [[message]]
 ==== Message
 
@@ -413,6 +421,36 @@
   $ git push exp
 ----
 
+[[trace]]
+==== Trace
+
+When pushing to Gerrit tracing can be enabled by setting the
+`trace=<trace-id>` push option. It is recommended to use the ID of the
+issue that is being investigated as trace ID.
+
+----
+  git push -o trace=issue/123 ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+.Example Request
+----
+  git push -o trace ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. This trace ID is returned in
+the command output:
+
+----
+  remote: TRACE_ID: 1534174322774-7edf2a7b
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
 
 [[push_replace]]
 === Replace Changes
diff --git a/README.md b/README.md
index 1ca01d5..844ee48 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
 ## Getting in contact
 
 The IRC channel on freenode is #gerrit. An archive is available at:
-[echelog.com](http://echelog.com/logs/browse/gerrit).
+[echelog.com](https://echelog.com/logs/browse/gerrit).
 
 The Developer Mailing list is [repo-discuss on Google Groups](https://groups.google.com/forum/#!forum/repo-discuss).
 
@@ -58,7 +58,7 @@
 ## Install binary packages (Deb/Rpm)
 
 The instruction how to configure GerritForge/BinTray repositories is
-[here](http://gitenterprise.me/2015/02/27/gerrit-2-10-rpm-and-debian-packages-available)
+[here](https://gitenterprise.me/2015/02/27/gerrit-2-10-rpm-and-debian-packages-available/)
 
 On Debian/Ubuntu run:
 
diff --git a/WORKSPACE b/WORKSPACE
index 8f1ff3e..7d7b9b1 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,7 +1,7 @@
 workspace(name = "gerrit")
 
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
-load("//tools/bzl:maven_jar.bzl", "maven_jar", "GERRIT", "MAVEN_LOCAL")
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
 
@@ -14,11 +14,9 @@
 
 http_archive(
     name = "io_bazel_rules_closure",
-    build_file_content = "exports_files([\"0001-Replace-native-http-git-_archive-with-Skylark-rules.patch\"])",
-    patches = ["//:0001-Replace-native-http-git-_archive-with-Skylark-rules.patch"],
-    sha256 = "a80acb69c63d5f6437b099c111480a4493bad4592015af2127a2f49fb7512d8d",
-    strip_prefix = "rules_closure-0.7.0",
-    url = "https://github.com/bazelbuild/rules_closure/archive/0.7.0.tar.gz",
+    sha256 = "4dd84dd2bdd6c9f56cb5a475d504ea31d199c34309e202e9379501d01c3067e5",
+    strip_prefix = "rules_closure-3103a773820b59b76345f94c231cb213e0d404e2",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -32,21 +30,23 @@
 
 load("@bazel_skylib//:lib.bzl", "versions")
 
-versions.check(minimum_bazel_version = "0.14.0")
+versions.check(minimum_bazel_version = "0.17.1")
 
 load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
 
 # Prevent redundant loading of dependencies.
+# TODO(davido): Omit re-fetching ancient args4j version when these PRs are merged:
+# https://github.com/bazelbuild/rules_closure/pull/262
+# https://github.com/google/closure-templates/pull/155
 closure_repositories(
     omit_aopalliance = True,
-    omit_args4j = True,
     omit_javax_inject = True,
 )
 
 ANTLR_VERS = "3.5.2"
 
 maven_jar(
-    name = "java_runtime",
+    name = "java-runtime",
     artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
     sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
 )
@@ -58,7 +58,7 @@
 )
 
 maven_jar(
-    name = "org_antlr",
+    name = "org-antlr",
     artifact = "org.antlr:antlr:" + ANTLR_VERS,
     sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
 )
@@ -73,19 +73,19 @@
 GUICE_VERS = "4.2.0"
 
 maven_jar(
-    name = "guice_library",
+    name = "guice-library",
     artifact = "com.google.inject:guice:" + GUICE_VERS,
     sha1 = "25e1f4c1d528a1cffabcca0d432f634f3132f6c8",
 )
 
 maven_jar(
-    name = "guice_assistedinject",
+    name = "guice-assistedinject",
     artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
     sha1 = "e7270305960ad7db56f7e30cb9df6be9ff1cfb45",
 )
 
 maven_jar(
-    name = "guice_servlet",
+    name = "guice-servlet",
     artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
     sha1 = "f57581625c36c148f088d9f52a568d5bdf12c61d",
 )
@@ -103,7 +103,7 @@
 )
 
 maven_jar(
-    name = "servlet_api_3_1",
+    name = "servlet-api-3_1",
     artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
     sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
 )
@@ -123,14 +123,14 @@
 )
 
 maven_jar(
-    name = "javax_validation",
+    name = "javax-validation",
     artifact = "javax.validation:validation-api:1.0.0.GA",
     sha1 = "b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e",
     src_sha1 = "7a561191db2203550fbfa40d534d4997624cd369",
 )
 
 maven_jar(
-    name = "jsinterop_annotations",
+    name = "jsinterop-annotations",
     artifact = "com.google.jsinterop:jsinterop-annotations:1.0.2",
     sha1 = "abd7319f53d018e11108a88f599bd16492448dd2",
     src_sha1 = "33716f8aef043f2f02b78ab4a1acda6cd90a7602",
@@ -158,7 +158,7 @@
 )
 
 maven_jar(
-    name = "w3c_css_sac",
+    name = "w3c-css-sac",
     artifact = "org.w3c.css:sac:1.3",
     sha1 = "cdb2dcb4e22b83d6b32b93095f644c3462739e82",
 )
@@ -174,12 +174,12 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
-FLOGGER_VERS = "0.2"
+FLOGGER_VERS = "0.3.1"
 
 maven_jar(
     name = "flogger",
     artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-    sha1 = "a22d04ed3b84bae8ecf8aa6d4430ad000bcdf7b4",
+    sha1 = "585030fe1ec709760cbef997a459729fb965df0e",
 )
 
 maven_jar(
@@ -191,7 +191,7 @@
 maven_jar(
     name = "flogger-system-backend",
     artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-    sha1 = "b995c84b8443d6cfbd011a55719b63494b974c3a",
+    sha1 = "287b569d76abcd82f9de87fe41829fbc7ebd8ac9",
 )
 
 maven_jar(
@@ -203,12 +203,12 @@
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.4",
-    sha1 = "d0de1ca9b69e69d1d497ee3c6009d015f64dad57",
+    artifact = "com.google.code.gson:gson:2.8.5",
+    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
 maven_jar(
-    name = "gwtorm_client",
+    name = "gwtorm-client",
     artifact = "com.google.gerrit:gwtorm:1.18",
     sha1 = "f326dec463439a92ccb32f05b38345e21d0b5ecf",
     src_sha1 = "e0b973d5cafef3d145fa80cdf032fcead1186d29",
@@ -220,7 +220,7 @@
     sha1 = "8c3492f7662fa1cbf8ca76a0f5eb1146f7725acd",
 )
 
-load("//lib:guava.bzl", "GUAVA_VERSION", "GUAVA_BIN_SHA1")
+load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
 
 maven_jar(
     name = "guava",
@@ -249,25 +249,25 @@
 SLF4J_VERS = "1.7.7"
 
 maven_jar(
-    name = "log_api",
+    name = "log-api",
     artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
     sha1 = "2b8019b6249bb05d81d3a3094e468753e2b21311",
 )
 
 maven_jar(
-    name = "log_ext",
+    name = "log-ext",
     artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
     sha1 = "09a8f58c784c37525d2624062414358acf296717",
 )
 
 maven_jar(
-    name = "impl_log4j",
+    name = "impl-log4j",
     artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
     sha1 = "58f588119ffd1702c77ccab6acb54bfb41bed8bd",
 )
 
 maven_jar(
-    name = "jcl_over_slf4j",
+    name = "jcl-over-slf4j",
     artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
     sha1 = "56003dcd0a31deea6391b9e2ef2f2dc90b205a92",
 )
@@ -279,68 +279,68 @@
 )
 
 maven_jar(
-    name = "jsonevent_layout",
+    name = "jsonevent-layout",
     artifact = "net.logstash.log4j:jsonevent-layout:1.7",
     sha1 = "507713504f0ddb75ba512f62763519c43cf46fde",
 )
 
 maven_jar(
-    name = "json_smart",
+    name = "json-smart",
     artifact = "net.minidev:json-smart:1.1.1",
     sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
 )
 
 maven_jar(
-    name = "args4j",
-    artifact = "args4j:args4j:2.0.26",
-    sha1 = "01ebb18ebb3b379a74207d5af4ea7c8338ebd78b",
+    name = "args4j-intern",
+    artifact = "args4j:args4j:2.0.29",
+    sha1 = "55ca4ddc4e906ffbaec043113b36bb410a3d909e",
 )
 
 maven_jar(
-    name = "commons_codec",
+    name = "commons-codec",
     artifact = "commons-codec:commons-codec:1.10",
     sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
 )
 
 # When upgrading commons-compress, also upgrade tukaani-xz
 maven_jar(
-    name = "commons_compress",
+    name = "commons-compress",
     artifact = "org.apache.commons:commons-compress:1.15",
     sha1 = "b686cd04abaef1ea7bc5e143c080563668eec17e",
 )
 
 maven_jar(
-    name = "commons_lang",
+    name = "commons-lang",
     artifact = "commons-lang:commons-lang:2.6",
     sha1 = "0ce1edb914c94ebc388f086c6827e8bdeec71ac2",
 )
 
 maven_jar(
-    name = "commons_lang3",
+    name = "commons-lang3",
     artifact = "org.apache.commons:commons-lang3:3.6",
     sha1 = "9d28a6b23650e8a7e9063c04588ace6cf7012c17",
 )
 
 maven_jar(
-    name = "commons_dbcp",
+    name = "commons-dbcp",
     artifact = "commons-dbcp:commons-dbcp:1.4",
     sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
 )
 
 maven_jar(
-    name = "commons_pool",
+    name = "commons-pool",
     artifact = "commons-pool:commons-pool:1.5.5",
     sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
 )
 
 maven_jar(
-    name = "commons_net",
+    name = "commons-net",
     artifact = "commons-net:commons-net:3.6",
     sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
 )
 
 maven_jar(
-    name = "commons_validator",
+    name = "commons-validator",
     artifact = "commons-validator:commons-validator:1.6",
     sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
 )
@@ -351,22 +351,163 @@
     sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
 )
 
+FLEXMARK_VERS = "0.34.18"
+
 maven_jar(
-    name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.6.0",
-    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
+    name = "flexmark",
+    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
+    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
 )
 
 maven_jar(
-    name = "grappa",
-    artifact = "com.github.parboiled1:grappa:1.0.4",
-    sha1 = "ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5",
+    name = "flexmark-ext-abbreviation",
+    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
+    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
 )
 
 maven_jar(
-    name = "jitescript",
-    artifact = "me.qmx.jitescript:jitescript:0.4.0",
-    sha1 = "2e35862b0435c1b027a21f3d6eecbe50e6e08d54",
+    name = "flexmark-ext-anchorlink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
+    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+)
+
+maven_jar(
+    name = "flexmark-ext-autolink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
+    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+)
+
+maven_jar(
+    name = "flexmark-ext-definition",
+    artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
+    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+)
+
+maven_jar(
+    name = "flexmark-ext-emoji",
+    artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
+    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+)
+
+maven_jar(
+    name = "flexmark-ext-escaped-character",
+    artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
+    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+)
+
+maven_jar(
+    name = "flexmark-ext-footnotes",
+    artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
+    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-issues",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
+    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-strikethrough",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
+    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-tables",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
+    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-tasklist",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
+    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-users",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
+    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+)
+
+maven_jar(
+    name = "flexmark-ext-ins",
+    artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
+    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+)
+
+maven_jar(
+    name = "flexmark-ext-jekyll-front-matter",
+    artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
+    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+)
+
+maven_jar(
+    name = "flexmark-ext-superscript",
+    artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
+    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+)
+
+maven_jar(
+    name = "flexmark-ext-tables",
+    artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
+    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+)
+
+maven_jar(
+    name = "flexmark-ext-toc",
+    artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
+    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+)
+
+maven_jar(
+    name = "flexmark-ext-typographic",
+    artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
+    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+)
+
+maven_jar(
+    name = "flexmark-ext-wikilink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
+    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+)
+
+maven_jar(
+    name = "flexmark-ext-yaml-front-matter",
+    artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
+    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+)
+
+maven_jar(
+    name = "flexmark-formatter",
+    artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
+    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+)
+
+maven_jar(
+    name = "flexmark-html-parser",
+    artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
+    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+)
+
+maven_jar(
+    name = "flexmark-profile-pegdown",
+    artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
+    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+)
+
+maven_jar(
+    name = "flexmark-util",
+    artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
+    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+)
+
+# Transitive dependency of flexmark
+maven_jar(
+    name = "autolink",
+    artifact = "org.nibor.autolink:autolink:0.7.0",
+    sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
 )
 
 GREENMAIL_VERS = "1.5.5"
@@ -388,13 +529,13 @@
 MIME4J_VERS = "0.8.1"
 
 maven_jar(
-    name = "mime4j_core",
+    name = "mime4j-core",
     artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
     sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
 )
 
 maven_jar(
-    name = "mime4j_dom",
+    name = "mime4j-dom",
     artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
     sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
 )
@@ -405,55 +546,55 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "6.0"
+OW2_VERS = "6.2.1"
 
 maven_jar(
-    name = "ow2_asm",
+    name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "bc6fa6b19424bb9592fe43bbc20178f92d403105",
+    sha1 = "c01b6798f81b0fc2c5faa70cbe468c275d4b50c7",
 )
 
 maven_jar(
-    name = "ow2_asm_analysis",
+    name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "dd1cc1381a970800268160203aae2d3784da779b",
+    sha1 = "e8b876c5ccf226cae2f44ed2c436ad3407d0ec1d",
 )
 
 maven_jar(
-    name = "ow2_asm_commons",
+    name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "f256fd215d8dd5a4fa2ab3201bf653de266ed4ec",
+    sha1 = "eaf31376d741a3e2017248a4c759209fe25c77d3",
 )
 
 maven_jar(
-    name = "ow2_asm_tree",
+    name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "a624f1a6e4e428dcd680a01bab2d4c56b35b18f0",
+    sha1 = "332b022092ecec53cdb6272dc436884b2d940615",
 )
 
 maven_jar(
-    name = "ow2_asm_util",
+    name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "430b2fc839b5de1f3643b528853d5cf26096c1de",
+    sha1 = "400d664d7c92a659d988c00cb65150d1b30cf339",
 )
 
-AUTO_VALUE_VERSION = "1.6"
+AUTO_VALUE_VERSION = "1.6.2"
 
 maven_jar(
-    name = "auto_value",
+    name = "auto-value",
     artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "a3b1b1404f8acaa88594a017185e013cd342c9a8",
+    sha1 = "e7eae562942315a983eea3e191b72d755c153620",
 )
 
 maven_jar(
-    name = "auto_value_annotations",
+    name = "auto-value-annotations",
     artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "da725083ee79fdcd86d9f3d8a76e38174a01892a",
+    sha1 = "ed193d86e0af90cc2342aedbe73c5d86b03fa09b",
 )
 
 # Transitive dependency of commons-compress
 maven_jar(
-    name = "tukaani_xz",
+    name = "tukaani-xz",
     artifact = "org.tukaani:xz:1.6",
     sha1 = "05b6f921f1810bdf90e25471968f741f87168b64",
 )
@@ -461,37 +602,37 @@
 LUCENE_VERS = "5.5.4"
 
 maven_jar(
-    name = "lucene_core",
+    name = "lucene-core",
     artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
     sha1 = "ab9c77e75cf142aa6e284b310c8395617bd9b19b",
 )
 
 maven_jar(
-    name = "lucene_analyzers_common",
+    name = "lucene-analyzers-common",
     artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
     sha1 = "08ce9d34c8124c80e176e8332ee947480bbb9576",
 )
 
 maven_jar(
-    name = "backward_codecs",
+    name = "backward-codecs",
     artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
     sha1 = "a933f42e758c54c43083398127ea7342b54d8212",
 )
 
 maven_jar(
-    name = "lucene_misc",
+    name = "lucene-misc",
     artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
     sha1 = "a74388857f73614e528ae44d742c60187cb55a5a",
 )
 
 maven_jar(
-    name = "lucene_queryparser",
+    name = "lucene-queryparser",
     artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
     sha1 = "8a06fad4675473d98d93b61fea529e3f464bf69e",
 )
 
 maven_jar(
-    name = "mime_util",
+    name = "mime-util",
     artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
     attach_source = False,
     sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
@@ -502,7 +643,7 @@
 PROLOG_REPO = GERRIT
 
 maven_jar(
-    name = "prolog_runtime",
+    name = "prolog-runtime",
     artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
@@ -510,7 +651,7 @@
 )
 
 maven_jar(
-    name = "prolog_compiler",
+    name = "prolog-compiler",
     artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
@@ -518,7 +659,7 @@
 )
 
 maven_jar(
-    name = "prolog_io",
+    name = "prolog-io",
     artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
     attach_source = False,
     repository = PROLOG_REPO,
@@ -534,7 +675,7 @@
 )
 
 maven_jar(
-    name = "guava_retrying",
+    name = "guava-retrying",
     artifact = "com.github.rholder:guava-retrying:2.0.0",
     sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
 )
@@ -546,7 +687,7 @@
 )
 
 maven_jar(
-    name = "blame_cache",
+    name = "blame-cache",
     artifact = "com/google/gitiles:blame-cache:0.2-6",
     attach_source = False,
     repository = GERRIT,
@@ -561,7 +702,7 @@
 )
 
 maven_jar(
-    name = "html_types",
+    name = "html-types",
     artifact = "com.google.common.html.types:types:1.0.4",
     sha1 = "2adf4c8bfccc0ff7346f9186ac5aa57d829ad065",
 )
@@ -573,30 +714,30 @@
 )
 
 maven_jar(
-    name = "dropwizard_core",
-    artifact = "io.dropwizard.metrics:metrics-core:4.0.2",
-    sha1 = "ec9878842d510cabd6bd6a9da1bebae1ae0cd199",
+    name = "dropwizard-core",
+    artifact = "io.dropwizard.metrics:metrics-core:4.0.3",
+    sha1 = "bb562ee73f740bb6b2bf7955f97be6b870d9e9f0",
 )
 
 # When updading Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.59"
+BC_VERS = "1.60"
 
 maven_jar(
     name = "bcprov",
     artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
-    sha1 = "2507204241ab450456bdb8e8c0a8f986e418bd99",
+    sha1 = "bd47ad3bd14b8e82595c7adaa143501e60842a84",
 )
 
 maven_jar(
     name = "bcpg",
     artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-    sha1 = "ee93e5376bb6cf0a15c027b5f5e4393f2738e709",
+    sha1 = "13c7a199c484127daad298996e95818478431a2c",
 )
 
 maven_jar(
     name = "bcpkix",
     artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-    sha1 = "9cef0aab8a4bb849a8476c058ce3ff302aba3fff",
+    sha1 = "d0c46320fbc07be3a24eb13a56cee4e3d38e0c75",
 )
 
 # TODO(davido): Remove exlusion of file system provider, when this issue is fixed:
@@ -615,7 +756,7 @@
 )
 
 maven_jar(
-    name = "mina_core",
+    name = "mina-core",
     artifact = "org.apache.mina:mina-core:2.0.16",
     sha1 = "f720f17643eaa7b0fec07c1d7f6272972c02bba4",
 )
@@ -632,7 +773,7 @@
 HTTPCOMP_VERS = "4.4.1"
 
 maven_jar(
-    name = "fluent_hc",
+    name = "fluent-hc",
     artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
     sha1 = "96fb842b68a44cc640c661186828b60590c71261",
 )
@@ -670,42 +811,41 @@
 )
 
 maven_jar(
-    name = "hamcrest_core",
+    name = "hamcrest-core",
     artifact = "org.hamcrest:hamcrest-core:1.3",
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-# Only needed when jgit is built from the development tree
-maven_jar(
-    name = "hamcrest_library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
-TRUTH_VERS = "0.40"
+TRUTH_VERS = "0.42"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "0d74e716afec045cc4a178dbbfde2a8314ae5574",
+    sha1 = "b5768f644b114e6cf5c3962c2ebcb072f788dcbb",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "636e49d675bc28e0b3ae0edd077d6acbbb159166",
+    sha1 = "4d01dfa5b3780632a3d109e14e101f01d10cce2c",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "21210ac07e5cfbe83f04733f806224a6c0ae4d2d",
+    sha1 = "c231e6735aa6c133c7e411ae1c1c90b124900a8b",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "5a2b504143a5fec2b6be8bce292b3b7577a81789",
+    sha1 = "c41d22e8b4a61b4171e57c44a2959ebee0091a14",
+)
+
+maven_jar(
+    name = "diffutils",
+    artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
+    sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
 # When bumping the easymock version number, make sure to also move powermock to a compatible version
@@ -716,9 +856,9 @@
 )
 
 maven_jar(
-    name = "cglib_3_2",
-    artifact = "cglib:cglib-nodep:3.2.0",
-    sha1 = "cf1ca207c15b04ace918270b6cb3f5601160cdfd",
+    name = "cglib-3_2",
+    artifact = "cglib:cglib-nodep:3.2.6",
+    sha1 = "92bf48723d277d6efd1150b2f7e9e1e92cb56caf",
 )
 
 maven_jar(
@@ -730,37 +870,37 @@
 POWERM_VERS = "1.6.1"
 
 maven_jar(
-    name = "powermock_module_junit4",
+    name = "powermock-module-junit4",
     artifact = "org.powermock:powermock-module-junit4:" + POWERM_VERS,
     sha1 = "ea8530b2848542624f110a393513af397b37b9cf",
 )
 
 maven_jar(
-    name = "powermock_module_junit4_common",
+    name = "powermock-module-junit4-common",
     artifact = "org.powermock:powermock-module-junit4-common:" + POWERM_VERS,
     sha1 = "7222ced54dabc310895d02e45c5428ca05193cda",
 )
 
 maven_jar(
-    name = "powermock_reflect",
+    name = "powermock-reflect",
     artifact = "org.powermock:powermock-reflect:" + POWERM_VERS,
     sha1 = "97d25eda8275c11161bcddda6ef8beabd534c878",
 )
 
 maven_jar(
-    name = "powermock_api_easymock",
+    name = "powermock-api-easymock",
     artifact = "org.powermock:powermock-api-easymock:" + POWERM_VERS,
     sha1 = "aa740ecf89a2f64d410b3d93ef8cd6833009ef00",
 )
 
 maven_jar(
-    name = "powermock_api_support",
+    name = "powermock-api-support",
     artifact = "org.powermock:powermock-api-support:" + POWERM_VERS,
     sha1 = "592ee6d929c324109d3469501222e0c76ccf0869",
 )
 
 maven_jar(
-    name = "powermock_core",
+    name = "powermock-core",
     artifact = "org.powermock:powermock-core:" + POWERM_VERS,
     sha1 = "5afc1efce8d44ed76b30af939657bd598e45d962",
 )
@@ -778,64 +918,64 @@
     sha1 = "df4b50061e8e4c348ce243b921f53ee63ba9bbe1",
 )
 
-JETTY_VERS = "9.4.9.v20180320"
+JETTY_VERS = "9.3.18.v20170406"
 
 maven_jar(
-    name = "jetty_servlet",
+    name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "d4453b746bc581af6ec5bce09228dc802bec1040",
+    sha1 = "534e7fa0e4fb6e08f89eb3f6a8c48b4f81ff5738",
 )
 
 maven_jar(
-    name = "jetty_security",
+    name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "dadd28ef757d9b8cdd1d7eef7fcbfa0b482c4648",
+    sha1 = "16b900e91b04511f42b706c925c8af6023d2c05e",
 )
 
 maven_jar(
-    name = "jetty_servlets",
+    name = "jetty-servlets",
     artifact = "org.eclipse.jetty:jetty-servlets:" + JETTY_VERS,
-    sha1 = "cb40696bb683655b7abb4ca72ad08708cd99ca7b",
+    sha1 = "f9311d1d8e6124d2792f4db5b29514d0ecf46812",
 )
 
 maven_jar(
-    name = "jetty_server",
+    name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "08847f7278e8ace7a1f5847e71563c8a10546582",
+    sha1 = "0a32feea88cba2d43951d22b60861c643454bb3f",
 )
 
 maven_jar(
-    name = "jetty_jmx",
+    name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "ff0978e1c74c4e08517df4d1950e61450ea987b1",
+    sha1 = "f988136dc5aa634afed6c5a35d910ee9599c6c23",
 )
 
 maven_jar(
-    name = "jetty_continuation",
+    name = "jetty-continuation",
     artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
-    sha1 = "590a07c7daf76c755e2daefb1aa0a91b41b26d87",
+    sha1 = "3c5d89c8204d4a48a360087f95e4cbd4520b5de0",
 )
 
 maven_jar(
-    name = "jetty_http",
+    name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "64d93698196ea7a66b33c754a0eac2a97d5af4b6",
+    sha1 = "30ece6d732d276442d513b94d914de6fa1075fae",
 )
 
 maven_jar(
-    name = "jetty_io",
+    name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "938d67c72405285d2a7a6efb10d870a1b16fa2e0",
+    sha1 = "36cb411ee89be1b527b0c10747aa3153267fc3ec",
 )
 
 maven_jar(
-    name = "jetty_util",
+    name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "8a602b93581f6af54839728f51d51ab830bdd44d",
+    sha1 = "8600b7d028a38cb462eff338de91390b3ff5040e",
 )
 
 maven_jar(
-    name = "openid_consumer",
+    name = "openid-consumer",
     artifact = "org.openid4java:openid4java:0.9.8",
     sha1 = "de4f1b33d3b0f0b2ab1d32834ec1190b39db4160",
 )
@@ -855,33 +995,33 @@
 
 maven_jar(
     name = "postgresql",
-    artifact = "org.postgresql:postgresql:9.4.1211",
-    sha1 = "721e3017fab68db9f0b08537ec91b8d757973ca8",
+    artifact = "org.postgresql:postgresql:42.2.4",
+    sha1 = "dff98730c28a4b3a3263f0cf4abb9a3392f815a7",
 )
 
 maven_jar(
-    name = "codemirror_minified",
+    name = "codemirror-minified-gwt",
     artifact = "org.webjars.npm:codemirror-minified:" + CM_VERSION,
-    sha1 = "f84c178b11a188f416b4380bfb2b24f126453d28",
+    sha1 = "36558ea3b8e30782e1e09c0e7bd781e09614f139",
 )
 
 maven_jar(
-    name = "codemirror_original",
+    name = "codemirror-original-gwt",
     artifact = "org.webjars.npm:codemirror:" + CM_VERSION,
-    sha1 = "5a1f6c10d5aef0b9d2ce513dcc1e2657e4af730d",
+    sha1 = "f1f8fbbc3e2d224fdccc43d2f4180658a92320f9",
 )
 
 maven_jar(
-    name = "diff_match_patch",
+    name = "diff-match-patch",
     artifact = "org.webjars:google-diff-match-patch:" + DIFF_MATCH_PATCH_VERSION,
     attach_source = False,
     sha1 = "0cf1782dbcb8359d95070da9176059a5a9d37709",
 )
 
 maven_jar(
-    name = "commons_io",
-    artifact = "commons-io:commons-io:1.4",
-    sha1 = "a8762d07e76cfde2395257a5da47ba7c1dbd3dce",
+    name = "commons-io",
+    artifact = "commons-io:commons-io:2.2",
+    sha1 = "83b5b8a7ba1c08f9e8c8ff2373724e33d3c1e22a",
 )
 
 maven_jar(
@@ -898,14 +1038,14 @@
 
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:5.6.9",
-    sha1 = "895706412e2fba3f842fca82ec3dece1cb4ee7d1",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.4.1",
+    sha1 = "b5b52703e8d798a71e1269c2eda585dff720436f",
 )
 
 JACKSON_VERSION = "2.8.9"
 
 maven_jar(
-    name = "jackson_core",
+    name = "jackson-core",
     artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERSION,
     sha1 = "569b1752705da98f49aabe2911cc956ff7d8ed9d",
 )
@@ -917,25 +1057,25 @@
 )
 
 maven_jar(
-    name = "httpcore_nio",
+    name = "httpcore-nio",
     artifact = "org.apache.httpcomponents:httpcore-nio:" + HTTPCOMP_VERS,
     sha1 = "a8c5e3c3bfea5ce23fb647c335897e415eb442e3",
 )
 
 maven_jar(
     name = "testcontainers",
-    artifact = "org.testcontainers:testcontainers:1.7.2",
-    sha1 = "fec8b360b6b613f6c9d3b8e7a9fa32d1a2bcb978",
+    artifact = "org.testcontainers:testcontainers:1.8.0",
+    sha1 = "bc413912f7044f9f12aa0782853aef0a067ee52a",
 )
 
 maven_jar(
-    name = "duct_tape",
+    name = "duct-tape",
     artifact = "org.rnorth.duct-tape:duct-tape:1.0.7",
     sha1 = "a26b5d90d88c91321dc7a3734ea72d2fc019ebb6",
 )
 
 maven_jar(
-    name = "visible_assertions",
+    name = "visible-assertions",
     artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.0",
     sha1 = "f2fcff2862860828ac38a5e1f14d941787c06b13",
 )
@@ -946,14 +1086,18 @@
     sha1 = "65bd0cacc9c79a21c6ed8e9f588577cd3c2f85b9",
 )
 
-load("//tools/bzl:js.bzl", "npm_binary", "bower_archive")
+load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
+# NPM binaries bundled along with their dependencies.
+#
+# For full instructions on adding new binaries to the build, see
+# http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
 npm_binary(
     name = "bower",
 )
 
 npm_binary(
-    name = "vulcanize",
+    name = "polymer-bundler",
     repository = GERRIT,
 )
 
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 6d7102a..fc96715 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -15,3 +15,17 @@
     ],
     visibility = ["//visibility:public"],
 )
+
+java_library(
+    name = "query_parser",
+    srcs = [":query"],
+    visibility = [
+        "//java/com/google/gerrit/index:__pkg__",
+        "//javatests/com/google/gerrit/index:__pkg__",
+        "//plugins:__pkg__",
+    ],
+    deps = [
+        "//java/com/google/gerrit/index:query_exception",
+        "//lib/antlr:java-runtime",
+    ],
+)
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 3501b8b..2e01131 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -103,6 +103,9 @@
                       default=None,
                       action='store',
                       help='only abandon changes owned by the given user')
+    parser.add_option('--exclude-wip', dest='exclude_wip',
+                      action='store_true',
+                      help='Exclude changes that are Work-in-Progress')
     parser.add_option('-v', '--verbose', dest='verbose',
                       action='store_true',
                       help='enable verbose (debug) logging')
@@ -148,7 +151,9 @@
         if options.testmode:
             query_terms = ["status:new", "owner:self", "topic:test-abandon"]
         else:
-            query_terms = ["status:new", "-is:wip", "age:%s" % options.age]
+            query_terms = ["status:new", "age:%s" % options.age]
+        if options.exclude_wip:
+            query_terms += ["-is:wip"]
         if options.branches:
             query_terms += ["branch:%s" % b for b in options.branches]
         elif options.exclude_branches:
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 866d74f..0f786a6 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.info;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
@@ -30,8 +32,6 @@
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -447,18 +447,8 @@
 
     public static void sortRevisionInfoByNumber(JsArray<RevisionInfo> list) {
       final int editParent = findEditParent(list);
-      Collections.sort(
-          Natives.asList(list),
-          new Comparator<RevisionInfo>() {
-            @Override
-            public int compare(RevisionInfo a, RevisionInfo b) {
-              return num(a) - num(b);
-            }
-
-            private int num(RevisionInfo r) {
-              return !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent;
-            }
-          });
+      Natives.asList(list)
+          .sort(comparing(r -> !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent));
     }
 
     public static int findEditParent(JsArray<RevisionInfo> list) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
index 345a260..fc3dbf1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
-import java.util.Collections;
 import java.util.Comparator;
 
 public class FileInfo extends JavaScriptObject {
@@ -55,8 +54,7 @@
   public final native void _row(int r) /*-{ this._row = r }-*/;
 
   public static void sortFileInfoByPath(JsArray<FileInfo> list) {
-    Collections.sort(
-        Natives.asList(list), Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
+    Natives.asList(list).sort(Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
   }
 
   public static String getFileName(String path) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
index 1dcb284..fbdf52c 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
@@ -151,6 +151,9 @@
   public final native boolean
       publishCommentsOnPush() /*-{ return this.publish_comments_on_push || false }-*/;
 
+  public final native boolean
+      workInProgressByDefault() /*-{ return this.work_in_progress_by_default || false }-*/;
+
   public final native JsArray<TopMenuItem> my() /*-{ return this.my; }-*/;
 
   public final native void changesPerPage(int n) /*-{ this.changes_per_page = n }-*/;
@@ -230,6 +233,9 @@
   public final native void publishCommentsOnPush(
       boolean p) /*-{ this.publish_comments_on_push = p }-*/;
 
+  public final native void workInProgressByDefault(
+      boolean p) /*-{ this.work_in_progress_by_default = p }-*/;
+
   public final void setMyMenus(List<TopMenuItem> myMenus) {
     initMy();
     for (TopMenuItem n : myMenus) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
index 4b17068..41306ff 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.client.rpc;
 
+import static java.util.stream.Collectors.toCollection;
+
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
@@ -57,10 +58,7 @@
   }
 
   public final List<String> sortedKeys() {
-    Set<String> keys = keySet();
-    List<String> sorted = new ArrayList<>(keys);
-    Collections.sort(sorted);
-    return sorted;
+    return keySet().stream().sorted().collect(toCollection(ArrayList::new));
   }
 
   public final native JsArray<T> values() /*-{
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
index 56ac0ea..1a772f1 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -1,7 +1,7 @@
 load(
     "//tools/bzl:gwt.bzl",
-    "gwt_genrule",
     "gen_ui_module",
+    "gwt_genrule",
     "gwt_user_agent_permutations",
 )
 load("//tools/bzl:license.bzl", "license_test")
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index 74fcdc2..c8d052e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -126,7 +126,7 @@
     if (size == 0) {
       return Resources.C.notAvailable();
     }
-    int p = Math.abs(Math.round(delta * 100 / size));
-    return p + "%";
+    long percentage = Math.abs(Math.round(delta * 100.0 / size));
+    return percentage + "%";
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
index 6138e8b..afc65c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
@@ -51,7 +51,6 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GerritTopMenu;
-import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.core.client.EntryPoint;
@@ -583,12 +582,10 @@
 
     btmmenu.add(new InlineHTML(M.poweredBy(vs)));
 
-    if (info().gerrit().webUis().contains(UiType.POLYGERRIT)) {
-      btmmenu.add(new InlineLabel(" | "));
-      uiSwitcherLink = new Anchor(C.newUi(), getUiSwitcherUrl(History.getToken()));
-      uiSwitcherLink.setStyleName("");
-      btmmenu.add(uiSwitcherLink);
-    }
+    btmmenu.add(new InlineLabel(" | "));
+    uiSwitcherLink = new Anchor(C.newUi(), getUiSwitcherUrl(History.getToken()));
+    uiSwitcherLink.setStyleName("");
+    btmmenu.add(uiSwitcherLink);
 
     String reportBugUrl = info().gerrit().reportBugUrl();
     if (reportBugUrl != null) {
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 b115c7d..88635df 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,8 @@
 public class ProjectAccessInfo extends JavaScriptObject {
   public final native boolean canAddRefs() /*-{ return this.can_add ? true : false; }-*/;
 
+  public final native boolean canAddTagRefs() /*-{ return this.can_add_tags ? true : false; }-*/;
+
   public final native boolean isOwner() /*-{ return this.is_owner ? true : false; }-*/;
 
   public final native boolean configVisible() /*-{ return this.config_visible ? true : false; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index 9799723..3e21619 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -69,6 +69,8 @@
 
   String publishCommentsOnPush();
 
+  String workInProgressByDefault();
+
   String myMenu();
 
   String myMenuInfo();
@@ -129,6 +131,8 @@
 
   String buttonGeneratePassword();
 
+  String revokePassword();
+
   String linkObtainPassword();
 
   String linkEditFullName();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index ca7cc27..c32efed 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -39,6 +39,7 @@
 muteCommonPathPrefixes = Mute Common Path Prefixes In File List
 signedOffBy = Insert Signed-off-by Footer For Inline Edit Changes
 publishCommentsOnPush = Publish Comments On Push
+workInProgressByDefault = Set all new changes work-in-progress by default
 myMenu = My Menu
 myMenuInfo = \
   Menu items for the 'My' top level menu. \
@@ -74,6 +75,7 @@
 confirmSetUserName = Setting the Username is permanent.  Are you sure?
 buttonClearPassword = Clear Password
 buttonGeneratePassword = Generate Password
+revokePassword = (click 'Generate Password' to revoke an old password)
 linkObtainPassword = Obtain Password
 linkEditFullName = Edit
 linkReloadContact = Reload
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
index 0dc1dab..1a0090a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.account;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.GpgKeyInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -40,8 +42,6 @@
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class MyGpgKeysScreen extends SettingsScreen {
@@ -118,14 +118,7 @@
                     List<GpgKeyInfo> list = Natives.asList(result.values());
                     // TODO(dborowitz): Sort on something more meaningful, like
                     // created date?
-                    Collections.sort(
-                        list,
-                        new Comparator<GpgKeyInfo>() {
-                          @Override
-                          public int compare(GpgKeyInfo a, GpgKeyInfo b) {
-                            return a.id().compareTo(b.id());
-                          }
-                        });
+                    list.sort(comparing(GpgKeyInfo::id));
                     keys.clear();
                     keyText.setText("");
                     errorPanel.setVisible(false);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
index 5c6d40f..730d98e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.account;
 
+import static java.util.Comparator.naturalOrder;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -29,7 +31,6 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 
@@ -169,7 +170,7 @@
 
     void display(JsArray<ExternalIdInfo> results) {
       List<ExternalIdInfo> idList = Natives.asList(results);
-      Collections.sort(idList);
+      idList.sort(naturalOrder());
 
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
index 3852387..5dd7530 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
@@ -49,7 +49,7 @@
       return;
     }
 
-    password = new CopyableLabel("(click 'generate' to revoke an old password)");
+    password = new CopyableLabel(Util.C.revokePassword());
     password.addStyleName(Gerrit.RESOURCES.css().accountPassword());
 
     generatePassword = new Button(Util.C.buttonGeneratePassword());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index f349065..afb8718 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -56,6 +56,7 @@
   private CheckBox muteCommonPathPrefixes;
   private CheckBox signedOffBy;
   private CheckBox publishCommentsOnPush;
+  private CheckBox workInProgressByDefault;
   private ListBox maximumPageSize;
   private ListBox dateFormat;
   private ListBox timeFormat;
@@ -163,9 +164,10 @@
     muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
     signedOffBy = new CheckBox(Util.C.signedOffBy());
     publishCommentsOnPush = new CheckBox(Util.C.publishCommentsOnPush());
+    workInProgressByDefault = new CheckBox(Util.C.workInProgressByDefault());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(15 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(16 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
 
@@ -229,6 +231,10 @@
     formGrid.setWidget(row, fieldIdx, publishCommentsOnPush);
     row++;
 
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, workInProgressByDefault);
+    row++;
+
     if (flashClippy) {
       formGrid.setText(row, labelIdx, "");
       formGrid.setWidget(row, fieldIdx, useFlashClipboard);
@@ -264,6 +270,7 @@
     e.listenTo(muteCommonPathPrefixes);
     e.listenTo(signedOffBy);
     e.listenTo(publishCommentsOnPush);
+    e.listenTo(workInProgressByDefault);
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
     e.listenTo(emailStrategy);
@@ -303,6 +310,7 @@
     muteCommonPathPrefixes.setEnabled(on);
     signedOffBy.setEnabled(on);
     publishCommentsOnPush.setEnabled(on);
+    workInProgressByDefault.setEnabled(on);
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
     emailStrategy.setEnabled(on);
@@ -329,6 +337,7 @@
     muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
     signedOffBy.setValue(p.signedOffBy());
     publishCommentsOnPush.setValue(p.publishCommentsOnPush());
+    workInProgressByDefault.setValue(p.workInProgressByDefault());
     setListBox(
         reviewCategoryStrategy,
         GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
@@ -421,6 +430,7 @@
     p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
     p.signedOffBy(signedOffBy.getValue());
     p.publishCommentsOnPush(publishCommentsOnPush.getValue());
+    p.workInProgressByDefault(workInProgressByDefault.getValue());
     p.reviewCategoryStrategy(
         getListBox(
             reviewCategoryStrategy, ReviewCategoryStrategy.NONE, ReviewCategoryStrategy.values()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
index 80c6d1a..4fdd067 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
@@ -41,7 +41,7 @@
   // corresponding regular expressions in the
   // com.google.gerrit.server.account.externalids.ExternalId class.
   private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
-  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9._@-]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
 
   private CopyableLabel userNameLbl;
   private NpTextBox userNameTxt;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index e518d26..7bd8b82 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.stream.Collectors.toCollection;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.LabelType;
@@ -45,7 +47,6 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.ValueListBox;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 public class AccessSectionEditor extends Composite
@@ -169,7 +170,7 @@
 
   @Override
   public void setValue(AccessSection value) {
-    Collections.sort(value.getPermissions());
+    sortPermissions(value);
 
     this.value = value;
     this.readOnly = !editing || !(projectAccess.isOwnerOf(value) || projectAccess.canUpload());
@@ -204,6 +205,11 @@
     }
   }
 
+  private void sortPermissions(AccessSection accessSection) {
+    accessSection.setPermissions(
+        accessSection.getPermissions().stream().sorted().collect(toCollection(ArrayList::new)));
+  }
+
   void setEditing(boolean editing) {
     this.editing = editing;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 2614224..6eaab5d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
@@ -29,7 +31,6 @@
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -295,26 +296,9 @@
 
     void insert(AccountInfo info) {
       Comparator<AccountInfo> c =
-          new Comparator<AccountInfo>() {
-            @Override
-            public int compare(AccountInfo a, AccountInfo b) {
-              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-              if (cmp != 0) {
-                return cmp;
-              }
-
-              cmp = nullToEmpty(a.email()).compareTo(nullToEmpty(b.email()));
-              if (cmp != 0) {
-                return cmp;
-              }
-
-              return a._accountId() - b._accountId();
-            }
-
-            public String nullToEmpty(String str) {
-              return str == null ? "" : str;
-            }
-          };
+          comparing((AccountInfo a) -> nullToEmpty(a.name()))
+              .thenComparing(a -> nullToEmpty(a.email()))
+              .thenComparing(AccountInfo::_accountId);
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -405,20 +389,7 @@
 
     void insert(GroupInfo info) {
       Comparator<GroupInfo> c =
-          new Comparator<GroupInfo>() {
-            @Override
-            public int compare(GroupInfo a, GroupInfo b) {
-              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-              if (cmp != 0) {
-                return cmp;
-              }
-              return a.getGroupUUID().compareTo(b.getGroupUUID());
-            }
-
-            private String nullToEmpty(@Nullable String str) {
-              return (str == null) ? "" : str;
-            }
-          };
+          comparing((GroupInfo g) -> nullToEmpty(g.name())).thenComparing(GroupInfo::getGroupUUID);
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -457,4 +428,9 @@
       setRowItem(row, i);
     }
   }
+
+  // Like Guava's Strings#nullToEmpty, which can't be used in GWT UI code.
+  private static String nullToEmpty(String str) {
+    return str == null ? "" : str;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index c0947a8..9def3b3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -79,6 +79,8 @@
 
   String privateByDefault();
 
+  String workInProgressByDefault();
+
   String enableReviewerByEmail();
 
   String matchAuthorToCommitterDate();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 8d6878f..7901f33 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -31,6 +31,7 @@
 requireChangeID = Require <code>Change-Id</code> in commit message
 rejectImplicitMerges = Reject implicit merges when changes are pushed for review
 privateByDefault = Set all new changes private by default
+workInProgressByDefault = Set all new changes work-in-progress by default
 headingMaxObjectSizeLimit = Maximum Git object size limit
 headingGroupOptions = Group Options
 isVisibleToAll = Make group visible to all registered users.
@@ -138,6 +139,7 @@
 	createTag, \
 	createSignedTag, \
 	delete, \
+	deleteChanges, \
 	deleteOwnChanges, \
 	editAssignee, \
 	editHashtags, \
@@ -161,6 +163,7 @@
 createTag = Create Annotated Tag
 createSignedTag = Create Signed Tag
 delete = Delete Reference
+deleteChanges = Delete Changes
 deleteOwnChanges = Delete Own Changes
 editAssignee = Edit Assignee
 editHashtags = Edit Hashtags
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
index 0c2f6fa..7b18a39 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
@@ -36,7 +36,7 @@
 
   String effectiveMaxObjectSizeLimit(String effectiveMaxObjectSizeLimit);
 
-  String globalMaxObjectSizeLimit(String globalMaxObjectSizeLimit);
+  String noMaxObjectSizeLimit();
 
   String pluginProjectOptionsTitle(String pluginName);
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
index 6338920..c9aa987 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
@@ -5,8 +5,8 @@
 deletedGroup = Deleted Group {0}
 deletedReference = Reference {0} was deleted
 deletedSection = Section {0} was deleted
-effectiveMaxObjectSizeLimit = effective: {0}
-globalMaxObjectSizeLimit = The global max object size limit is set to {0}. The limit cannot be increased on project level.
+effectiveMaxObjectSizeLimit = effective: {0} bytes
+noMaxObjectSizeLimit = No max object size limit is set.
 pluginProjectOptionsTitle = {0} Plugin Options
 pluginProjectOptionsTitle = {0} Plugin
 pluginProjectInheritedValue = inherited: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 259847e..be0db41 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupList;
@@ -32,8 +34,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.HTMLTable.Cell;
 import com.google.gwt.user.client.ui.Image;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class GroupTable extends NavigationTable<GroupInfo> {
@@ -105,14 +105,7 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(
-        list,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            return a.name().compareTo(b.name());
-          }
-        });
+    list.sort(comparing(GroupInfo::name));
     for (GroupInfo group : list.subList(fromIndex, toIndex)) {
       final int row = table.getRowCount();
       table.insertRow(row);
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 64e147d..d10a031 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
@@ -88,6 +88,7 @@
   private ListBox requireSignedPush;
   private ListBox rejectImplicitMerges;
   private ListBox privateByDefault;
+  private ListBox workInProgressByDefault;
   private ListBox enableReviewerByEmail;
   private ListBox matchAuthorToCommitterDate;
   private NpTextBox maxObjectSizeLimit;
@@ -198,6 +199,7 @@
     requireChangeID.setEnabled(isOwner);
     rejectImplicitMerges.setEnabled(isOwner);
     privateByDefault.setEnabled(isOwner);
+    workInProgressByDefault.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
     enableReviewerByEmail.setEnabled(isOwner);
     matchAuthorToCommitterDate.setEnabled(isOwner);
@@ -278,6 +280,10 @@
     saveEnabler.listenTo(privateByDefault);
     grid.addHtml(AdminConstants.I.privateByDefault(), privateByDefault);
 
+    workInProgressByDefault = newInheritedBooleanBox();
+    saveEnabler.listenTo(workInProgressByDefault);
+    grid.addHtml(AdminConstants.I.workInProgressByDefault(), workInProgressByDefault);
+
     enableReviewerByEmail = newInheritedBooleanBox();
     saveEnabler.listenTo(enableReviewerByEmail);
     grid.addHtml(AdminConstants.I.enableReviewerByEmail(), enableReviewerByEmail);
@@ -427,19 +433,20 @@
     }
     setBool(rejectImplicitMerges, result.rejectImplicitMerges());
     setBool(privateByDefault, result.privateByDefault());
+    setBool(workInProgressByDefault, result.workInProgressByDefault());
     setBool(enableReviewerByEmail, result.enableReviewerByEmail());
     setBool(matchAuthorToCommitterDate, result.matchAuthorToCommitterDate());
     setSubmitType(result.defaultSubmitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
-    if (result.maxObjectSizeLimit().inheritedValue() != null) {
-      effectiveMaxObjectSizeLimit.setVisible(true);
+    if (result.maxObjectSizeLimit().value() != null) {
       effectiveMaxObjectSizeLimit.setText(
           AdminMessages.I.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
-      effectiveMaxObjectSizeLimit.setTitle(
-          AdminMessages.I.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
+      if (result.maxObjectSizeLimit().summary() != null) {
+        effectiveMaxObjectSizeLimit.setTitle(result.maxObjectSizeLimit().summary());
+      }
     } else {
-      effectiveMaxObjectSizeLimit.setVisible(false);
+      effectiveMaxObjectSizeLimit.setText(AdminMessages.I.noMaxObjectSizeLimit());
     }
 
     saveProject.setEnabled(false);
@@ -505,6 +512,9 @@
       textBox.setValue(param.value());
       addWidget(g, textBox, param);
     }
+    if (textBox.getValue().length() > textBox.getVisibleLength()) {
+      textBox.setVisibleLength(textBox.getValue().length());
+    }
     saveEnabler.listenTo(textBox);
     return textBox;
   }
@@ -700,6 +710,7 @@
         rsp,
         getBool(rejectImplicitMerges),
         getBool(privateByDefault),
+        getBool(workInProgressByDefault),
         getBool(enableReviewerByEmail),
         getBool(matchAuthorToCommitterDate),
         maxObjectSizeLimit.getText().trim(),
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
index 18e4176..22c331d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
@@ -94,7 +94,7 @@
         new GerritCallback<ProjectAccessInfo>() {
           @Override
           public void onSuccess(ProjectAccessInfo result) {
-            addPanel.setVisible(result.canAddRefs());
+            addPanel.setVisible(result.canAddTagRefs());
           }
         });
     query = new Query(match).start(start).run();
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 cb6fe28..ec7bc4a 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
@@ -1259,11 +1259,13 @@
   }
 
   private void renderSubmitType(Change.Status status, boolean canSubmit, SubmitType submitType) {
-    if (canSubmit && status == Change.Status.NEW && !changeInfo.isWorkInProgress()) {
-      statusText.setInnerText(
-          changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
+    if (status == Change.Status.NEW && !changeInfo.isWorkInProgress()) {
+      if (canSubmit) {
+        statusText.setInnerText(
+            changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
+      }
+      setVisible(notMergeable, !changeInfo.mergeable());
     }
-    setVisible(notMergeable, !changeInfo.mergeable());
     submitActionText.setInnerText(com.google.gerrit.client.admin.Util.toLongString(submitType));
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index 1f4820f..801a927 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.client.change;
 
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toCollection;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.Util;
@@ -135,9 +139,12 @@
   }
 
   void set(ChangeInfo info) {
-    List<String> names = new ArrayList<>(info.labels());
+    List<String> names =
+        info.labels()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
     Set<Integer> removable = info.removableReviewerIds();
-    Collections.sort(names);
 
     resize(names.size(), 2);
 
@@ -197,8 +204,7 @@
   }
 
   private static List<Integer> sort(Set<Integer> keySet, int a, int b) {
-    List<Integer> r = new ArrayList<>(keySet);
-    Collections.sort(r);
+    List<Integer> r = keySet.stream().sorted().collect(toCollection(ArrayList::new));
     if (keySet.contains(a)) {
       r.remove(Integer.valueOf(a));
       r.add(0, a);
@@ -238,31 +244,32 @@
       Set<Integer> removable,
       String label,
       Map<Integer, VotableInfo> votable) {
-    List<AccountInfo> users = new ArrayList<>(in);
-    Collections.sort(
-        users,
-        new Comparator<AccountInfo>() {
-          @Override
-          public int compare(AccountInfo a, AccountInfo b) {
-            String as = name(a);
-            String bs = name(b);
-            if (as.isEmpty()) {
-              return 1;
-            } else if (bs.isEmpty()) {
-              return -1;
-            }
-            return as.compareTo(bs);
-          }
+    List<AccountInfo> users =
+        in.stream()
+            .sorted(
+                new Comparator<AccountInfo>() {
+                  @Override
+                  public int compare(AccountInfo a, AccountInfo b) {
+                    String as = name(a);
+                    String bs = name(b);
+                    if (as.isEmpty()) {
+                      return 1;
+                    } else if (bs.isEmpty()) {
+                      return -1;
+                    }
+                    return as.compareTo(bs);
+                  }
 
-          private String name(AccountInfo a) {
-            if (a.name() != null) {
-              return a.name();
-            } else if (a.email() != null) {
-              return a.email();
-            }
-            return "";
-          }
-        });
+                  private String name(AccountInfo a) {
+                    if (a.name() != null) {
+                      return a.name();
+                    } else if (a.email() != null) {
+                      return a.email();
+                    }
+                    return "";
+                  }
+                })
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
 
     SafeHtmlBuilder html = new SafeHtmlBuilder();
     Iterator<? extends AccountInfo> itr = users.iterator();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
index 0bbd614..8a1a2d5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
@@ -16,6 +16,8 @@
 
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_ENTER;
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_MAC_ENTER;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.ChangeApi;
@@ -123,11 +125,15 @@
     this.lc = new LocalComments(project, psId.getParentKey());
     initWidget(uiBinder.createAndBindUi(this));
 
-    List<String> names = new ArrayList<>(permitted.keySet());
+    List<String> names =
+        permitted
+            .keySet()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
     if (names.isEmpty()) {
       UIObject.setVisible(labelsParent, false);
     } else {
-      Collections.sort(names);
       renderLabels(names, all, permitted);
     }
 
@@ -439,8 +445,11 @@
               clp, project, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
     }
 
-    List<String> paths = new ArrayList<>(m.keySet());
-    Collections.sort(paths);
+    List<String> paths =
+        m.keySet()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
 
     for (String path : paths) {
       if (!Patch.isMagic(path)) {
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 c6e4e2f..7ec1102 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.changes;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.info.ChangeInfo;
@@ -176,7 +178,7 @@
       }
     }
 
-    Collections.sort(Natives.asList(out), outComparator());
+    Natives.asList(out).sort(outComparator());
 
     table.updateColumnsForLabels(wip, out, in, done);
     workInProgress.display(wip);
@@ -187,16 +189,7 @@
   }
 
   private Comparator<ChangeInfo> outComparator() {
-    return new Comparator<ChangeInfo>() {
-      @Override
-      public int compare(ChangeInfo a, ChangeInfo b) {
-        int cmp = a.created().compareTo(b.created());
-        if (cmp != 0) {
-          return cmp;
-        }
-        return a._number() - b._number();
-      }
-    };
+    return comparing(ChangeInfo::created).thenComparing(ChangeInfo::_number);
   }
 
   private boolean hasChanges(JsArray<ChangeList> result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index caea87e..4fda78b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -16,11 +16,13 @@
 
 import static com.google.gerrit.client.FormatUtil.relativeFormat;
 import static com.google.gerrit.client.FormatUtil.shortFormat;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountLinkPanel;
 import com.google.gerrit.client.ui.BranchLink;
 import com.google.gerrit.client.ui.ChangeLink;
@@ -45,6 +47,7 @@
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
@@ -185,17 +188,13 @@
   }
 
   public void updateColumnsForLabels(ChangeList... lists) {
-    labelNames = new ArrayList<>();
-    for (ChangeList list : lists) {
-      for (int i = 0; i < list.length(); i++) {
-        for (String name : list.get(i).labels()) {
-          if (!labelNames.contains(name)) {
-            labelNames.add(name);
-          }
-        }
-      }
-    }
-    Collections.sort(labelNames);
+    labelNames =
+        Arrays.stream(lists)
+            .flatMap(l -> Natives.asList(l).stream())
+            .flatMap(c -> c.labels().stream())
+            .distinct()
+            .sorted()
+            .collect(toList());
 
     int baseColumns = BASE_COLUMNS;
     if (baseColumns + labelNames.size() < columns) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
index 0e4ef4e..dcb9c01 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.dashboards;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.NavigationTable;
@@ -25,8 +27,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.Image;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -75,14 +75,7 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(
-        list,
-        new Comparator<DashboardInfo>() {
-          @Override
-          public int compare(DashboardInfo a, DashboardInfo b) {
-            return a.id().compareTo(b.id());
-          }
-        });
+    list.sort(comparing(DashboardInfo::id));
 
     String ref = null;
     for (DashboardInfo d : list) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index 533b745..2698584 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentApi;
@@ -27,8 +29,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.Collections;
-import java.util.Comparator;
 
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
@@ -158,14 +158,7 @@
       for (CommentInfo c : Natives.asList(in)) {
         c.path(path);
       }
-      Collections.sort(
-          Natives.asList(in),
-          new Comparator<CommentInfo>() {
-            @Override
-            public int compare(CommentInfo a, CommentInfo b) {
-              return a.updated().compareTo(b.updated());
-            }
-          });
+      Natives.asList(in).sort(comparing(CommentInfo::updated));
     }
     return in;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
index cf40762..d942c2e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
@@ -137,7 +137,10 @@
 
   private static void append(StringBuilder s, JsArrayString lines) {
     for (int i = 0; i < lines.length(); i++) {
-      s.append(lines.get(i)).append('\n');
+      if (s.length() > 0) {
+        s.append('\n');
+      }
+      s.append(lines.get(i));
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 1a662e2..98ad023 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.diff.DiffInfo.Region;
 import com.google.gerrit.client.diff.DiffInfo.Span;
 import com.google.gerrit.client.rpc.Natives;
@@ -227,12 +229,7 @@
 
   /** Diff chunks are ordered by their starting lines in CodeMirror */
   private Comparator<UnifiedDiffChunkInfo> getDiffChunkComparatorCmLine() {
-    return new Comparator<UnifiedDiffChunkInfo>() {
-      @Override
-      public int compare(UnifiedDiffChunkInfo o1, UnifiedDiffChunkInfo o2) {
-        return o1.getCmLine() - o2.getCmLine();
-      }
-    };
+    return comparing(UnifiedDiffChunkInfo::getCmLine);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index f670ac7..684f8e6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -60,6 +60,9 @@
   public final native InheritedBooleanInfo privateByDefault()
       /*-{ return this.private_by_default; }-*/ ;
 
+  public final native InheritedBooleanInfo workInProgressByDefault()
+      /*-{ return this.work_in_progress_by_default; }-*/ ;
+
   public final native InheritedBooleanInfo enableReviewerByEmail()
       /*-{ return this.enable_reviewer_by_email; }-*/ ;
 
@@ -172,10 +175,10 @@
   public static class MaxObjectSizeLimitInfo extends JavaScriptObject {
     public final native String value() /*-{ return this.value; }-*/;
 
-    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
-
     public final native String configuredValue() /*-{ return this.configured_value }-*/;
 
+    public final native String summary() /*-{ return this.summary; }-*/;
+
     protected MaxObjectSizeLimitInfo() {}
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index 66afdb2..3be52e6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -153,6 +153,7 @@
       InheritableBoolean requireSignedPush,
       InheritableBoolean rejectImplicitMerges,
       InheritableBoolean privateByDefault,
+      InheritableBoolean workInProgressByDefault,
       InheritableBoolean enableReviewerByEmail,
       InheritableBoolean matchAuthorToCommitterDate,
       String maxObjectSizeLimit,
@@ -175,6 +176,7 @@
     }
     in.setRejectImplicitMerges(rejectImplicitMerges);
     in.setPrivateByDefault(privateByDefault);
+    in.setWorkInProgressByDefault(workInProgressByDefault);
     in.setMaxObjectSizeLimit(maxObjectSizeLimit);
     if (submitType != null) {
       in.setSubmitType(submitType);
@@ -313,6 +315,13 @@
 
     private native void setPrivateByDefault(String v) /*-{ if(v)this.private_by_default=v; }-*/;
 
+    final void setWorkInProgressByDefault(InheritableBoolean v) {
+      setWorkInProgressByDefault(v.name());
+    }
+
+    private native void setWorkInProgressByDefault(
+        String v) /*-{ if(v)this.work_in_progress_by_default=v; }-*/;
+
     final void setEnableReviewerByEmail(InheritableBoolean v) {
       setEnableReviewerByEmailRaw(v.name());
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
index ac89180..3576b12 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.client.ui;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.projects.ProjectInfo;
 import com.google.gerrit.client.projects.ProjectMap;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.Image;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class ProjectsTable extends NavigationTable<ProjectInfo> {
@@ -69,14 +69,7 @@
     }
 
     List<ProjectInfo> list = Natives.asList(projects.values());
-    Collections.sort(
-        list,
-        new Comparator<ProjectInfo>() {
-          @Override
-          public int compare(ProjectInfo a, ProjectInfo b) {
-            return a.name().compareTo(b.name());
-          }
-        });
+    list.sort(comparing(ProjectInfo::name));
     for (ProjectInfo p : list.subList(fromIndex, toIndex)) {
       insert(table.getRowCount(), p);
     }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index c6f113e..9df066d 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -14,6 +14,8 @@
 
 package net.codemirror.mode;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
@@ -21,8 +23,6 @@
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.resources.client.DataResource;
 import com.google.gwt.safehtml.shared.SafeUri;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -242,14 +242,7 @@
         byMime.put(m.mode(), m);
       }
     }
-    Collections.sort(
-        Natives.asList(filtered),
-        new Comparator<ModeInfo>() {
-          @Override
-          public int compare(ModeInfo a, ModeInfo b) {
-            return a.name().toLowerCase().compareTo(b.name().toLowerCase());
-          }
-        });
+    Natives.asList(filtered).sort(comparing(m -> m.name().toLowerCase()));
     setAll(filtered);
   }
 
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
index 0880993..bfd977d 100644
--- a/gerrit-plugin-gwtui/BUILD
+++ b/gerrit-plugin-gwtui/BUILD
@@ -67,7 +67,7 @@
     libs = DEPS + [
         ":gwtui-api-lib",
         "//lib:gwtjsonrpc",
-        "//lib:gwtorm_client",
+        "//lib:gwtorm-client",
         "//lib/gwt:dev",
         "//gerrit-gwtui-common:client-lib",
         "//java/com/google/gerrit/common:client",
diff --git a/java/Main.java b/java/Main.java
index f26b6df..11d8234 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -14,6 +14,7 @@
 
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -42,6 +43,9 @@
   }
 
   private static void configureFloggerBackend() {
+    System.setProperty(
+        FLOGGER_LOGGING_CONTEXT, "com.google.gerrit.server.logging.LoggingContext#getInstance");
+
     if (System.getProperty(FLOGGER_BACKEND_PROPERTY) != null) {
       // Flogger backend is already configured
       return;
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index bf01615..69d603f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -33,6 +33,7 @@
 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.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
@@ -63,15 +64,21 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 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.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -108,8 +115,6 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.MutableNotesMigration;
@@ -146,6 +151,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -165,6 +171,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.FetchResult;
@@ -274,6 +281,7 @@
 
   @Inject private ChangeIndexCollection changeIndexes;
   @Inject private AccountIndexCollection accountIndexes;
+  @Inject private ProjectIndexCollection projectIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
   @Inject private InProcessProtocol inProcessProtocol;
   @Inject private Provider<AnonymousUser> anonymousUser;
@@ -397,6 +405,8 @@
       baseConfig.setString("sshd", null, "listenAddress", "off");
     }
 
+    baseConfig.setInt("index", null, "batchThreads", -1);
+
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
@@ -898,6 +908,41 @@
     };
   }
 
+  protected AutoCloseable disableProjectIndex() {
+    disableProjectIndexWrites();
+    ProjectIndex searchIndex = projectIndexes.getSearchIndex();
+    if (!(searchIndex instanceof DisabledProjectIndex)) {
+      projectIndexes.setSearchIndex(new DisabledProjectIndex(searchIndex), false);
+    }
+
+    return new AutoCloseable() {
+      @Override
+      public void close() {
+        enableProjectIndexWrites();
+        ProjectIndex searchIndex = projectIndexes.getSearchIndex();
+        if (searchIndex instanceof DisabledProjectIndex) {
+          projectIndexes.setSearchIndex(((DisabledProjectIndex) searchIndex).unwrap(), false);
+        }
+      }
+    };
+  }
+
+  protected void disableProjectIndexWrites() {
+    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
+      if (!(i instanceof DisabledProjectIndex)) {
+        projectIndexes.addWriteIndex(new DisabledProjectIndex(i));
+      }
+    }
+  }
+
+  protected void enableProjectIndexWrites() {
+    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
+      if (i instanceof DisabledProjectIndex) {
+        projectIndexes.addWriteIndex(((DisabledProjectIndex) i).unwrap());
+      }
+    }
+  }
+
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
@@ -1075,7 +1120,7 @@
       ProjectConfig config = ProjectConfig.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
-      p.getRules().clear();
+      p.clearRules();
       config.commit(md);
       projectCache.evict(config.getProject());
     }
@@ -1305,8 +1350,7 @@
 
   protected void assertDiffForNewFile(
       DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = new ArrayList<>();
-    Collections.addAll(expectedLines, expectedContentSideB.split("\n"));
+    List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
 
     assertThat(diff.binary).isNull();
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
@@ -1443,12 +1487,7 @@
     assertNotifyTo(expected.email, expected.fullName);
   }
 
-  protected void assertNotifyTo(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyTo(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
-  private void assertNotifyTo(String expectedEmail, String expectedFullname) {
+  protected void assertNotifyTo(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
@@ -1462,11 +1501,6 @@
     assertNotifyCc(expected.emailAddress);
   }
 
-  protected void assertNotifyCc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyCc(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
   protected void assertNotifyCc(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertNotifyCc(expectedAddress);
@@ -1489,13 +1523,10 @@
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
-  protected void assertNotifyBcc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
+  protected void assertNotifyBcc(String expectedEmail, String expectedFullName) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt())
-        .containsExactly(
-            new Address(expected.fullname().orElse(null), expected.preferredEmail().orElse(null)));
+    assertThat(m.rcpt()).containsExactly(new Address(expectedFullName, expectedEmail));
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
@@ -1572,16 +1603,38 @@
   }
 
   protected void configLabel(String label, LabelFunction func) throws Exception {
+    configLabel(label, func, ImmutableList.of());
+  }
+
+  protected void configLabel(String label, LabelFunction func, List<String> refPatterns)
+      throws Exception {
     configLabel(
-        project, label, func, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        project,
+        label,
+        func,
+        refPatterns,
+        value(1, "Passes"),
+        value(0, "No score"),
+        value(-1, "Failed"));
   }
 
   protected void configLabel(
       Project.NameKey project, String label, LabelFunction func, LabelValue... value)
       throws Exception {
+    configLabel(project, label, func, ImmutableList.of(), value);
+  }
+
+  private void configLabel(
+      Project.NameKey project,
+      String label,
+      LabelFunction func,
+      List<String> refPatterns,
+      LabelValue... value)
+      throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType labelType = category(label, value);
       labelType.setFunction(func);
+      labelType.setRefPatterns(refPatterns);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
       u.save();
     }
@@ -1634,4 +1687,29 @@
       }
     }
   }
+
+  protected List<RevCommit> getChangeMetaCommitsInReverseOrder(Change.Id changeId)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      revWalk.sort(RevSort.TOPO);
+      revWalk.sort(RevSort.REVERSE);
+      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
+      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
+      return Lists.newArrayList(revWalk);
+    }
+  }
+
+  protected List<CommentInfo> getChangeSortedComments(int changeNum) throws Exception {
+    List<CommentInfo> comments = new ArrayList<>();
+    Map<String, List<CommentInfo>> commentsMap = gApi.changes().id(changeNum).comments();
+    for (Map.Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
+      for (CommentInfo c : e.getValue()) {
+        c.path = e.getKey(); // Set the comment's path field.
+        comments.add(c);
+      }
+    }
+    comments.sort(Comparator.comparing(c -> c.id));
+    return comments;
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 2336f2f..df57c27 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.extensions.api.changes.RecipientType.BCC;
 import static com.google.gerrit.extensions.api.changes.RecipientType.CC;
@@ -34,10 +35,10 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.EmailHeader.AddressList;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import java.io.IOException;
@@ -100,7 +101,7 @@
 
     public FakeEmailSenderSubject notSent() {
       if (actual().peekMessage() != null) {
-        fail("a message wasn't sent");
+        failWithoutActual(fact("expected message", "sent"));
       }
       return this;
     }
@@ -108,7 +109,7 @@
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
       message = actual().nextMessage();
       if (message == null) {
-        fail("a message was sent");
+        failWithoutActual(fact("expected message", "not sent"));
       }
       recipients = new HashMap<>();
       recipients.put(TO, parseAddresses(message, "To"));
@@ -123,11 +124,12 @@
               .collect(toList()));
       this.users = users;
       if (!message.headers().containsKey("X-Gerrit-MessageType")) {
-        fail("a message was sent with X-Gerrit-MessageType header");
+        failWithoutActual(
+            fact("expected to have message sent with", "X-Gerrit-MessageType header"));
       }
       EmailHeader header = message.headers().get("X-Gerrit-MessageType");
       if (!header.equals(new EmailHeader.String(messageType))) {
-        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
+        failWithoutActual(fact("expected message of type", messageType));
       }
 
       // Return a named subject that displays a human-readable table of
@@ -191,9 +193,10 @@
 
     private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
       if (recipients.get(type).contains(email) != expected) {
-        fail(
-            expected ? "notifies" : "doesn't notify",
-            "]\n" + type + ": " + users.emailToName(email) + "\n]");
+        failWithoutActual(
+            fact(
+                expected ? "notifies" : "doesn't notify",
+                "[\n" + type + ": " + users.emailToName(email) + "\n]"));
       }
       if (expected) {
         accountedFor.add(email);
@@ -219,9 +222,10 @@
         }
       }
       if (!ok) {
-        fail(
-            "was fully tested, missing assertions for: "
-                + recipientMapToString(unaccountedFor, e -> users.emailToName(e)));
+        failWithoutActual(
+            fact(
+                "expected assertions for",
+                recipientMapToString(unaccountedFor, e -> users.emailToName(e))));
       }
       return this;
     }
@@ -282,7 +286,7 @@
 
     private void rcpt(@Nullable RecipientType type, NotifyType watch) {
       if (!users.watchers.containsKey(watch)) {
-        fail("configured to watch", watch);
+        failWithoutActual(fact("expected to be configured to watch", watch));
       }
       rcpt(type, users.watchers.get(watch));
     }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1416797..69492a9 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -150,6 +151,10 @@
     accounts.values().removeIf(a -> ids.contains(a.id));
   }
 
+  public ImmutableList<TestAccount> getAll() {
+    return ImmutableList.copyOf(accounts.values());
+  }
+
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
     InternalGroupUpdate groupUpdate =
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 770805b..0214cea 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -19,6 +19,7 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm",
         "//java/com/google/gerrit/pgm/init",
@@ -85,7 +86,7 @@
         "//lib/httpcomponents:httpcore",
         "//lib/jetty:servlet",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/log:impl_log4j",
+        "//lib/log:impl-log4j",
         "//lib/log:log4j",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
@@ -98,7 +99,9 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/reviewdb:server",
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
new file mode 100644
index 0000000..286b045
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2018 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+
+public class ChangeIndexedCounter implements ChangeIndexedListener {
+  private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+  @Override
+  public void onChangeIndexed(String projectName, int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  @Override
+  public void onChangeDeleted(int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  public void clear() {
+    countsByChange.clear();
+  }
+
+  long getCount(ChangeInfo info) {
+    return countsByChange.get(info._number);
+  }
+
+  public void assertReindexOf(ChangeInfo info) {
+    assertReindexOf(info, 1);
+  }
+
+  public void assertReindexOf(ChangeInfo info, int expectedCount) {
+    assertThat(getCount(info)).isEqualTo(expectedCount);
+    assertThat(countsByChange).hasSize(1);
+    clear();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
new file mode 100644
index 0000000..2524a76
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2018 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;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Project;
+
+/**
+ * This class wraps an index and assumes the search index can't handle any queries. However, it does
+ * return the current schema as the assumption is that we need a search index for starting Gerrit in
+ * the first place and only later lose the index connection (making it so that we can't send
+ * requests there anymore).
+ */
+public class DisabledProjectIndex implements ProjectIndex {
+  private final ProjectIndex index;
+
+  public DisabledProjectIndex(ProjectIndex index) {
+    this.index = index;
+  }
+
+  public ProjectIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<ProjectData> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(ProjectData obj) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void delete(Project.NameKey key) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void deleteAll() {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index f9f95b5..1af71b8 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeDeletedEvent;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.RefEvent;
@@ -63,11 +64,14 @@
 
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event e) {
                 if (e instanceof ReviewerDeletedEvent) {
                   recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                } else if (e instanceof ChangeDeletedEvent) {
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
                 } else if (e instanceof RefEvent) {
                   RefEvent event = (RefEvent) e;
                   String key =
@@ -137,6 +141,25 @@
     return events;
   }
 
+  private ImmutableList<ChangeDeletedEvent> getChangeDeletedEvents(int expectedSize) {
+    String key = ChangeDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeDeletedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
+    getRefUpdatedEvents(project, branch, 0);
+  }
+
   public void assertRefUpdatedEvents(String project, String branch, String... expected)
       throws Exception {
     ImmutableList<RefUpdatedEvent> events =
@@ -192,6 +215,18 @@
     }
   }
 
+  public void assertChangeDeletedEvents(String... expected) {
+    ImmutableList<ChangeDeletedEvent> events = getChangeDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ChangeDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.deleter.get().email;
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
   public void close() {
     eventListenerRegistration.remove();
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 6e5424c..582c7cb 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -24,6 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -441,6 +443,7 @@
             bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
             bind(AccountOperations.class).to(AccountOperationsImpl.class);
+            bind(GroupOperations.class).to(GroupOperationsImpl.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index b62e932..3e98d71 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.nio.ByteBuffer;
+import java.util.Arrays;
 import org.apache.http.Header;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -34,7 +39,7 @@
 
   public Reader getReader() throws IllegalStateException, IOException {
     if (reader == null && response.getEntity() != null) {
-      reader = new InputStreamReader(response.getEntity().getContent());
+      reader = new InputStreamReader(response.getEntity().getContent(), UTF_8);
     }
     return reader;
   }
@@ -59,6 +64,13 @@
     return hdr != null ? hdr.getValue() : null;
   }
 
+  public ImmutableList<String> getHeaders(String name) {
+    return Arrays.asList(response.getHeaders(name))
+        .stream()
+        .map(Header::getValue)
+        .collect(toImmutableList());
+  }
+
   public boolean hasContent() {
     Preconditions.checkNotNull(response, "Response is not initialized.");
     return response.getEntity() != null;
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 7e2796a..2f71f4e 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -332,13 +332,11 @@
 
         AsyncReceiveCommits arc =
             factory.create(projectState, identifiedUser, db, null, ImmutableSetMultimap.of());
-        ReceivePack rp = arc.getReceivePack();
-
-        Capable r = arc.canUpload();
-        if (r != Capable.OK) {
+        if (arc.canUpload() != Capable.OK) {
           throw new ServiceNotAuthorizedException();
         }
 
+        ReceivePack rp = arc.getReceivePack();
         rp.setRefLogIdent(identifiedUser.newRefLogIdent());
         rp.setTimeout(config.getTimeout());
         rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index f4a8da3..76ae4b0 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
@@ -214,7 +214,7 @@
     for (Map.Entry<Project.NameKey, Collection<String>> e :
         refsPatternByProject.asMap().entrySet()) {
       try (Repository repo = repoManager.openRepository(e.getKey())) {
-        Collection<Ref> refs = repo.getAllRefs().values();
+        Collection<Ref> refs = repo.getRefDatabase().getRefs();
         for (String refPattern : e.getValue()) {
           RefPatternMatcher matcher = RefPatternMatcher.getMatcher(refPattern);
           for (Ref ref : refs) {
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
index 7809ae0..5209f90 100644
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -24,14 +24,14 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
 
-public class ReadOnlyChangeIndex implements ChangeIndex {
+class ReadOnlyChangeIndex implements ChangeIndex {
   private final ChangeIndex index;
 
-  public ReadOnlyChangeIndex(ChangeIndex index) {
+  ReadOnlyChangeIndex(ChangeIndex index) {
     this.index = index;
   }
 
-  public ChangeIndex unwrap() {
+  ChangeIndex unwrap() {
     return index;
   }
 
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 300e75f..1a3c029 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.OutputFormat;
 import java.io.IOException;
 import org.apache.http.Header;
-import org.apache.http.HttpStatus;
 import org.apache.http.client.fluent.Request;
 import org.apache.http.entity.BufferedHttpEntity;
 import org.apache.http.entity.InputStreamEntity;
@@ -36,16 +35,6 @@
     super(server, account);
   }
 
-  public RestResponse getOK(String endPoint) throws Exception {
-    return get(endPoint, HttpStatus.SC_OK);
-  }
-
-  public RestResponse get(String endPoint, int expectedStatus) throws Exception {
-    RestResponse r = get(endPoint);
-    r.assertStatus(expectedStatus);
-    return r;
-  }
-
   public RestResponse get(String endPoint) throws IOException {
     return getWithHeader(endPoint, null);
   }
@@ -105,21 +94,11 @@
     return post(endPoint, null);
   }
 
-  public RestResponse postOK(String endPoint, Object content) throws Exception {
-    return post(endPoint, content, HttpStatus.SC_OK);
-  }
-
-  public RestResponse post(String endPoint, Object content, int expectedStatus) throws Exception {
-    RestResponse r = post(endPoint, content);
-    r.assertStatus(expectedStatus);
-    return r;
-  }
-
   public RestResponse post(String endPoint, Object content) throws IOException {
-    return postWithHeader(endPoint, content, null);
+    return postWithHeader(endPoint, null, content);
   }
 
-  public RestResponse postWithHeader(String endPoint, Object content, Header header)
+  public RestResponse postWithHeader(String endPoint, Header header, Object content)
       throws IOException {
     Request post = Request.Post(getUrl(endPoint));
     if (header != null) {
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 9e515ca..52d7f28 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.jcraft.jsch.ChannelExec;
@@ -52,10 +55,10 @@
       InputStream err = channel.getErrStream();
       channel.connect();
 
-      Scanner s = new Scanner(err).useDelimiter("\\A");
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
       error = s.hasNext() ? s.next() : null;
 
-      s = new Scanner(in).useDelimiter("\\A");
+      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
     } finally {
       channel.disconnect();
@@ -75,7 +78,7 @@
     return exec(command, null);
   }
 
-  public boolean hasError() {
+  private boolean hasError() {
     return error != null;
   }
 
@@ -83,6 +86,19 @@
     return error;
   }
 
+  public void assertSuccess() {
+    assertWithMessage(getError()).that(hasError()).isFalse();
+  }
+
+  public void assertFailure() {
+    assertThat(hasError()).isTrue();
+  }
+
+  public void assertFailure(String error) {
+    assertThat(hasError()).isTrue();
+    assertThat(getError()).contains(error);
+  }
+
   public void close() {
     if (session != null) {
       session.disconnect();
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index 094e8b0..5ce44ff 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -17,8 +17,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.net.InetAddresses;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
 import java.util.List;
diff --git a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java
similarity index 72%
copy from resources/com/google/gerrit/pgm/ProtoGenHeader.txt
copy to java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java
index a380955..5efdc81 100644
--- a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
+++ b/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2011 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-syntax = "proto2";
+package com.google.gerrit.acceptance.testsuite;
 
-option java_api_version = 2;
-option java_package = "com.google.gerrit.proto.reviewdb";
-
-package devtools.gerritcodereview;
+@FunctionalInterface
+public interface ThrowingConsumer<T> {
+  void accept(T t) throws Exception;
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index 58a00d0..61b7599 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -42,7 +42,7 @@
    * <p>Example:
    *
    * <pre>
-   * TestAccount createdAccount = accountOperations
+   * Account.Id createdAccountId = accountOperations
    *     .newAccount()
    *     .username("janedoe")
    *     .preferredEmail("janedoe@example.com")
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 3d741b0..ebbcfe4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -59,12 +59,12 @@
     return TestAccountCreation.builder(this::createAccount);
   }
 
-  private TestAccount createAccount(TestAccountCreation accountCreation) throws Exception {
+  private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
         (account, updateBuilder) ->
             fillBuilder(updateBuilder, accountCreation, account.getAccount().getId());
     AccountState createdAccount = createAccount(accountUpdater);
-    return toTestAccount(createdAccount);
+    return createdAccount.getAccount().getId();
   }
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
@@ -85,17 +85,6 @@
     accountCreation.active().ifPresent(builder::setActive);
   }
 
-  private static TestAccount toTestAccount(AccountState accountState) {
-    Account createdAccount = accountState.getAccount();
-    return TestAccount.builder()
-        .accountId(createdAccount.getId())
-        .preferredEmail(Optional.ofNullable(createdAccount.getPreferredEmail()))
-        .fullname(Optional.ofNullable(createdAccount.getFullName()))
-        .username(accountState.getUserName())
-        .active(accountState.getAccount().isActive())
-        .build();
-  }
-
   private static InternalAccountUpdate.Builder setPreferredEmail(
       InternalAccountUpdate.Builder builder, Account.Id accountId, String preferredEmail) {
     return builder
@@ -133,18 +122,28 @@
       return toTestAccount(account);
     }
 
+    private TestAccount toTestAccount(AccountState accountState) {
+      Account account = accountState.getAccount();
+      return TestAccount.builder()
+          .accountId(account.getId())
+          .preferredEmail(Optional.ofNullable(account.getPreferredEmail()))
+          .fullname(Optional.ofNullable(account.getFullName()))
+          .username(accountState.getUserName())
+          .active(accountState.getAccount().isActive())
+          .build();
+    }
+
     @Override
     public TestAccountUpdate.Builder forUpdate() {
       return TestAccountUpdate.builder(this::updateAccount);
     }
 
-    private TestAccount updateAccount(TestAccountUpdate accountUpdate)
+    private void updateAccount(TestAccountUpdate accountUpdate)
         throws OrmException, IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
           (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
-      return toTestAccount(updatedAccount.get());
     }
 
     private Optional<AccountState> updateAccount(AccountsUpdate.AccountUpdater accountUpdater)
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index a82d180..ab32409 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
 import java.util.Optional;
 
 @AutoValue
@@ -32,9 +33,9 @@
 
   public abstract Optional<Boolean> active();
 
-  abstract ThrowingFunction<TestAccountCreation, TestAccount> accountCreator();
+  abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
-  public static Builder builder(ThrowingFunction<TestAccountCreation, TestAccount> accountCreator) {
+  public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
     return new AutoValue_TestAccountCreation.Builder()
         .accountCreator(accountCreator)
         .httpPassword("http-pass");
@@ -83,11 +84,11 @@
     }
 
     abstract Builder accountCreator(
-        ThrowingFunction<TestAccountCreation, TestAccount> accountCreator);
+        ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
-    public TestAccount create() throws Exception {
+    public Account.Id create() throws Exception {
       TestAccountCreation accountUpdate = autoBuild();
       return accountUpdate.accountCreator().apply(accountUpdate);
     }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
index 517e4b5..251f452 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import java.util.Optional;
 
 @AutoValue
@@ -32,9 +32,9 @@
 
   public abstract Optional<Boolean> active();
 
-  abstract ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater();
+  abstract ThrowingConsumer<TestAccountUpdate> accountUpdater();
 
-  public static Builder builder(ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater) {
+  public static Builder builder(ThrowingConsumer<TestAccountUpdate> accountUpdater) {
     return new AutoValue_TestAccountUpdate.Builder()
         .accountUpdater(accountUpdater)
         .httpPassword("http-pass");
@@ -82,14 +82,13 @@
       return active(false);
     }
 
-    abstract Builder accountUpdater(
-        ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater);
+    abstract Builder accountUpdater(ThrowingConsumer<TestAccountUpdate> accountUpdater);
 
     abstract TestAccountUpdate autoBuild();
 
-    public TestAccount update() throws Exception {
+    public void update() throws Exception {
       TestAccountUpdate accountUpdate = autoBuild();
-      return accountUpdate.accountUpdater().apply(accountUpdate);
+      accountUpdate.accountUpdater().accept(accountUpdate);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
new file mode 100644
index 0000000..f75ca2e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+/**
+ * An aggregation of operations on groups for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface GroupOperations {
+  /**
+   * Starts the fluent chain for querying or modifying a group. Please see the methods of {@link
+   * MoreGroupOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific group
+   */
+  MoreGroupOperations group(AccountGroup.UUID groupUuid);
+
+  /**
+   * Starts the fluent chain to create a group. The returned builder can be used to specify the
+   * attributes of the new group. To create the group for real, {@link
+   * TestGroupCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * AccountGroup.UUID createdGroupUuid = groupOperations
+   *     .newGroup()
+   *     .name("verifiers")
+   *     .description("All verifiers of this server")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> If another group with the provided name already exists, the creation
+   * of the group will fail.
+   *
+   * @return a builder to create the new group
+   */
+  TestGroupCreation.Builder newGroup();
+
+  /** An aggregation of methods on a specific group. */
+  interface MoreGroupOperations {
+
+    /**
+     * Checks whether the group exists.
+     *
+     * @return {@code true} if the group exists
+     */
+    boolean exists() throws Exception;
+
+    /**
+     * Retrieves the group.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested group
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestGroup}
+     */
+    TestGroup get() throws Exception;
+
+    /**
+     * Starts the fluent chain to update a group. The returned builder can be used to specify how
+     * the attributes of the group should be modified. To update the group for real, {@link
+     * TestGroupUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * groupOperations.forUpdate().description("Another description for this group").update();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The update will fail with an exception if the group to update
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}.
+     *
+     * @return a builder to update the group
+     */
+    TestGroupUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
new file mode 100644
index 0000000..f9769c5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * The implementation of {@code GroupOperations}.
+ *
+ * <p>There is only one implementation of {@code GroupOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class GroupOperationsImpl implements GroupOperations {
+  private final Groups groups;
+  private final GroupsUpdate groupsUpdate;
+  private final Sequences seq;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  public GroupOperationsImpl(
+      Groups groups,
+      @ServerInitiated GroupsUpdate groupsUpdate,
+      Sequences seq,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this.groups = groups;
+    this.groupsUpdate = groupsUpdate;
+    this.seq = seq;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  public MoreGroupOperations group(AccountGroup.UUID groupUuid) {
+    return new MoreGroupOperationsImpl(groupUuid);
+  }
+
+  @Override
+  public TestGroupCreation.Builder newGroup() {
+    return TestGroupCreation.builder(this::createNewGroup);
+  }
+
+  private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
+      throws ConfigInvalidException, IOException, OrmException {
+    InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
+    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
+    InternalGroup internalGroup =
+        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    return internalGroup.getGroupUUID();
+  }
+
+  private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation)
+      throws OrmException {
+    AccountGroup.Id groupId = new AccountGroup.Id(seq.nextGroupId());
+    String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
+    AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
+    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(groupName);
+    return InternalGroupCreation.builder()
+        .setId(groupId)
+        .setGroupUUID(groupUuid)
+        .setNameKey(nameKey)
+        .build();
+  }
+
+  private static InternalGroupUpdate toInternalGroupUpdate(TestGroupCreation groupCreation) {
+    InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+    groupCreation.description().ifPresent(builder::setDescription);
+    groupCreation.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+    groupCreation.visibleToAll().ifPresent(builder::setVisibleToAll);
+    builder.setMemberModification(originalMembers -> groupCreation.members());
+    builder.setSubgroupModification(originalSubgroups -> groupCreation.subgroups());
+    return builder.build();
+  }
+
+  private class MoreGroupOperationsImpl implements MoreGroupOperations {
+    private final AccountGroup.UUID groupUuid;
+
+    MoreGroupOperationsImpl(AccountGroup.UUID groupUuid) {
+      this.groupUuid = groupUuid;
+    }
+
+    @Override
+    public boolean exists() throws Exception {
+      return groups.getGroup(groupUuid).isPresent();
+    }
+
+    @Override
+    public TestGroup get() throws Exception {
+      Optional<InternalGroup> group = groups.getGroup(groupUuid);
+      checkState(group.isPresent(), "Tried to get non-existing test group");
+      return toTestGroup(group.get());
+    }
+
+    private TestGroup toTestGroup(InternalGroup internalGroup) {
+      return TestGroup.builder()
+          .groupUuid(internalGroup.getGroupUUID())
+          .groupId(internalGroup.getId())
+          .nameKey(internalGroup.getNameKey())
+          .description(Optional.ofNullable(internalGroup.getDescription()))
+          .ownerGroupUuid(internalGroup.getOwnerGroupUUID())
+          .visibleToAll(internalGroup.isVisibleToAll())
+          .createdOn(internalGroup.getCreatedOn())
+          .members(internalGroup.getMembers())
+          .subgroups(internalGroup.getSubgroups())
+          .build();
+    }
+
+    @Override
+    public TestGroupUpdate.Builder forUpdate() {
+      return TestGroupUpdate.builder(this::updateGroup);
+    }
+
+    private void updateGroup(TestGroupUpdate groupUpdate)
+        throws OrmDuplicateKeyException, NoSuchGroupException, ConfigInvalidException, IOException {
+      InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, internalGroupUpdate);
+    }
+
+    private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
+      InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+      groupUpdate.name().map(AccountGroup.NameKey::new).ifPresent(builder::setName);
+      groupUpdate.description().ifPresent(builder::setDescription);
+      groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+      groupUpdate.visibleToAll().ifPresent(builder::setVisibleToAll);
+      builder.setMemberModification(groupUpdate.memberModification()::apply);
+      builder.setSubgroupModification(groupUpdate.subgroupModification()::apply);
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
new file mode 100644
index 0000000..b450304
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestGroup {
+
+  public abstract AccountGroup.UUID groupUuid();
+
+  public abstract AccountGroup.Id groupId();
+
+  public String name() {
+    return nameKey().get();
+  }
+
+  public abstract AccountGroup.NameKey nameKey();
+
+  public abstract Optional<String> description();
+
+  public abstract AccountGroup.UUID ownerGroupUuid();
+
+  public abstract boolean visibleToAll();
+
+  public abstract Timestamp createdOn();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  static Builder builder() {
+    return new AutoValue_TestGroup.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    public abstract Builder groupUuid(AccountGroup.UUID groupUuid);
+
+    public abstract Builder groupId(AccountGroup.Id id);
+
+    public abstract Builder nameKey(AccountGroup.NameKey name);
+
+    public abstract Builder description(String description);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder members(ImmutableSet<Account.Id> members);
+
+    public abstract Builder subgroups(ImmutableSet<AccountGroup.UUID> subgroups);
+
+    abstract TestGroup build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
new file mode 100644
index 0000000..efed720
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+
+@AutoValue
+public abstract class TestGroupCreation {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  abstract ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator();
+
+  public static Builder builder(
+      ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator) {
+    return new AutoValue_TestGroupCreation.Builder().groupCreator(groupCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public Builder clearMembers() {
+      return members(ImmutableSet.of());
+    }
+
+    public Builder members(Account.Id member1, Account.Id... otherMembers) {
+      return members(Sets.union(ImmutableSet.of(member1), ImmutableSet.copyOf(otherMembers)));
+    }
+
+    public abstract Builder members(Set<Account.Id> members);
+
+    abstract ImmutableSet.Builder<Account.Id> membersBuilder();
+
+    public Builder addMember(Account.Id member) {
+      membersBuilder().add(member);
+      return this;
+    }
+
+    public Builder clearSubgroups() {
+      return subgroups(ImmutableSet.of());
+    }
+
+    public Builder subgroups(AccountGroup.UUID subgroup1, AccountGroup.UUID... otherSubgroups) {
+      return subgroups(Sets.union(ImmutableSet.of(subgroup1), ImmutableSet.copyOf(otherSubgroups)));
+    }
+
+    public abstract Builder subgroups(Set<AccountGroup.UUID> subgroups);
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> subgroupsBuilder();
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      subgroupsBuilder().add(subgroup);
+      return this;
+    }
+
+    abstract Builder groupCreator(
+        ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator);
+
+    abstract TestGroupCreation autoBuild();
+
+    /**
+     * Executes the group creation as specified.
+     *
+     * @return the UUID of the created group
+     */
+    public AccountGroup.UUID create() throws Exception {
+      TestGroupCreation groupCreation = autoBuild();
+      return groupCreation.groupCreator().apply(groupCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
new file mode 100644
index 0000000..095a270
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+
+@AutoValue
+public abstract class TestGroupUpdate {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+  public abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+      subgroupModification();
+
+  abstract ThrowingConsumer<TestGroupUpdate> groupUpdater();
+
+  public static Builder builder(ThrowingConsumer<TestGroupUpdate> groupUpdater) {
+    return new AutoValue_TestGroupUpdate.Builder()
+        .groupUpdater(groupUpdater)
+        .memberModification(in -> in)
+        .subgroupModification(in -> in);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUUID);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    abstract Builder memberModification(
+        Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification);
+
+    abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+    public Builder clearMembers() {
+      return memberModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.union(previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    public Builder removeMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.difference(
+                  previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    abstract Builder subgroupModification(
+        Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> subgroupModification);
+
+    abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+        subgroupModification();
+
+    public Builder clearSubgroups() {
+      return subgroupModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.union(previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    public Builder removeSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.difference(
+                  previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    abstract Builder groupUpdater(ThrowingConsumer<TestGroupUpdate> groupUpdater);
+
+    abstract TestGroupUpdate autoBuild();
+
+    /** Executes the group update as specified. */
+    public void update() throws Exception {
+      TestGroupUpdate groupUpdater = autoBuild();
+      groupUpdater.groupUpdater().accept(groupUpdater);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
index 8b432ff..4e2ed8a 100644
--- a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
+++ b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
@@ -142,9 +142,9 @@
     try {
       parser.parseArgument(parameters);
       if (inputFiles.isEmpty()) {
-        throw new CmdLineException(parser, "asciidoctor: FAILED: input file missing");
+        throw new IllegalArgumentException("asciidoctor: FAILED: input file missing");
       }
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | IllegalArgumentException e) {
       System.err.println(e.getMessage());
       parser.printUsage(System.err);
       System.exit(1);
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 5dfde95..ef3ea3f 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -72,9 +72,9 @@
     try {
       parser.parseArgument(parameters);
       if (inputFiles.isEmpty()) {
-        throw new CmdLineException(parser, "FAILED: input file missing");
+        throw new IllegalArgumentException("FAILED: input file missing");
       }
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | IllegalArgumentException e) {
       System.err.println(e.getMessage());
       parser.printUsage(System.err);
       System.exit(1);
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 800a975..2122ebb 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -20,7 +20,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/prettify:client",
         "//lib:guava",
-        "//lib:gwtorm_client",
+        "//lib:gwtorm-client",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 82dc620..0530cc0 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -34,14 +34,17 @@
     super(refPattern);
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<Permission> getPermissions() {
     if (permissions == null) {
-      permissions = new ArrayList<>();
+      return new ArrayList<>();
     }
-    return permissions;
+    return new ArrayList<>(permissions);
   }
 
   public void setPermissions(List<Permission> list) {
+    checkNotNull(list);
+
     Set<String> names = new HashSet<>();
     for (Permission p : list) {
       if (!names.add(p.getName().toLowerCase())) {
@@ -49,7 +52,7 @@
       }
     }
 
-    permissions = list;
+    permissions = new ArrayList<>(list);
   }
 
   @Nullable
@@ -59,13 +62,21 @@
 
   @Nullable
   public Permission getPermission(String name, boolean create) {
-    for (Permission p : getPermissions()) {
-      if (p.getName().equalsIgnoreCase(name)) {
-        return p;
+    checkNotNull(name);
+
+    if (permissions != null) {
+      for (Permission p : permissions) {
+        if (p.getName().equalsIgnoreCase(name)) {
+          return p;
+        }
       }
     }
 
     if (create) {
+      if (permissions == null) {
+        permissions = new ArrayList<>();
+      }
+
       Permission p = new Permission(name);
       permissions.add(p);
       return p;
@@ -75,7 +86,12 @@
   }
 
   public void addPermission(Permission permission) {
-    List<Permission> permissions = getPermissions();
+    checkNotNull(permission);
+
+    if (permissions == null) {
+      permissions = new ArrayList<>();
+    }
+
     for (Permission p : permissions) {
       if (p.getName().equalsIgnoreCase(permission.getName())) {
         throw new IllegalArgumentException();
@@ -86,18 +102,21 @@
   }
 
   public void remove(Permission permission) {
-    if (permission != null) {
-      removePermission(permission.getName());
-    }
+    checkNotNull(permission);
+    removePermission(permission.getName());
   }
 
   public void removePermission(String name) {
+    checkNotNull(name);
+
     if (permissions != null) {
       permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
     }
   }
 
   public void mergeFrom(AccessSection section) {
+    checkNotNull(section);
+
     for (Permission src : section.getPermissions()) {
       Permission dst = getPermission(src.getName());
       if (dst != null) {
@@ -108,6 +127,13 @@
     }
   }
 
+  // TODO(ekempin): Once the GWT UI is gone use com.google.common.base.Preconditions.checkNotNull
+  private static void checkNotNull(Object reference) {
+    if (reference == null) {
+      throw new NullPointerException();
+    }
+  }
+
   @Override
   public int compareTo(AccessSection o) {
     return comparePattern().compareTo(o.comparePattern());
@@ -133,4 +159,15 @@
     return new HashSet<>(getPermissions())
         .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
   }
+
+  @Override
+  public int hashCode() {
+    int hashCode = super.hashCode();
+    if (permissions != null) {
+      for (Permission permission : permissions) {
+        hashCode += permission.hashCode();
+      }
+    }
+    return hashCode;
+  }
 }
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
index dc22d62..e5b0965 100644
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ b/java/com/google/gerrit/common/data/GroupReference.java
@@ -22,11 +22,6 @@
 
   private static final String PREFIX = "group ";
 
-  /** @return a new reference to the given group description. */
-  public static GroupReference forGroup(AccountGroup group) {
-    return new GroupReference(group.getGroupUUID(), group.getName());
-  }
-
   public static GroupReference forGroup(GroupDescription.Basic group) {
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
@@ -48,16 +43,23 @@
 
   protected GroupReference() {}
 
-  public GroupReference(AccountGroup.UUID uuid, String name) {
+  /**
+   * Create a group reference.
+   *
+   * @param uuid UUID of the group, may be {@code null} if the group name couldn't be resolved
+   * @param name the group name, must not be {@code null}
+   */
+  public GroupReference(@Nullable AccountGroup.UUID uuid, String name) {
     setUUID(uuid);
     setName(name);
   }
 
+  @Nullable
   public AccountGroup.UUID getUUID() {
     return uuid != null ? new AccountGroup.UUID(uuid) : null;
   }
 
-  public void setUUID(AccountGroup.UUID newUUID) {
+  public void setUUID(@Nullable AccountGroup.UUID newUUID) {
     uuid = newUUID != null ? newUUID.get() : null;
   }
 
@@ -66,6 +68,9 @@
   }
 
   public void setName(String newName) {
+    if (newName == null) {
+      throw new NullPointerException();
+    }
     this.name = newName;
   }
 
@@ -75,7 +80,11 @@
   }
 
   private static String uuid(GroupReference a) {
-    return a.getUUID() != null ? a.getUUID().get() : "?";
+    if (a.getUUID() != null && a.getUUID().get() != null) {
+      return a.getUUID().get();
+    }
+
+    return "?";
   }
 
   @Override
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 7bfd22e..ff7d25b 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 
 public class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
@@ -34,6 +36,7 @@
   public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
     checkName(name);
@@ -70,22 +73,13 @@
 
   private static List<LabelValue> sortValues(List<LabelValue> values) {
     values = new ArrayList<>(values);
-    if (values.size() <= 1) {
-      return Collections.unmodifiableList(values);
+    if (values.isEmpty()) {
+      return Collections.emptyList();
     }
-    Collections.sort(
-        values,
-        new Comparator<LabelValue>() {
-          @Override
-          public int compare(LabelValue o1, LabelValue o2) {
-            return o1.getValue() - o2.getValue();
-          }
-        });
-    short min = values.get(0).getValue();
-    short max = values.get(values.size() - 1).getValue();
-    short v = min;
+    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
+    short v = values.get(0).getValue();
     short i = 0;
-    List<LabelValue> result = new ArrayList<>(max - min + 1);
+    ArrayList<LabelValue> result = new ArrayList<>();
     // Fill in any missing values with empty text.
     while (i < values.size()) {
       while (v < values.get(i).getValue()) {
@@ -94,6 +88,7 @@
       v++;
       result.add(values.get(i++));
     }
+    result.trimToSize();
     return Collections.unmodifiableList(result);
   }
 
@@ -109,6 +104,7 @@
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
   protected boolean allowPostSubmit;
+  protected boolean ignoreSelfApproval;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -117,7 +113,6 @@
 
   private transient boolean canOverride;
   private transient List<String> refPatterns;
-  private transient List<Integer> intList;
   private transient Map<Short, LabelValue> byValue;
 
   protected LabelType() {}
@@ -148,6 +143,12 @@
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
+    setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
+
+    byValue = new HashMap<>();
+    for (LabelValue v : values) {
+      byValue.put(v.getValue(), v);
+    }
   }
 
   public String getName() {
@@ -162,11 +163,8 @@
     if (functionName == null) {
       return null;
     }
-    Optional<LabelFunction> f = LabelFunction.parse(functionName);
-    if (!f.isPresent()) {
-      throw new IllegalStateException("Unsupported functionName: " + functionName);
-    }
-    return f.get();
+    return LabelFunction.parse(functionName)
+        .orElseThrow(() -> new IllegalStateException("Unsupported functionName: " + functionName));
   }
 
   public void setFunction(@Nullable LabelFunction function) {
@@ -193,8 +191,21 @@
     this.allowPostSubmit = allowPostSubmit;
   }
 
+  public boolean ignoreSelfApproval() {
+    return ignoreSelfApproval;
+  }
+
+  public void setIgnoreSelfApproval(boolean ignoreSelfApproval) {
+    this.ignoreSelfApproval = ignoreSelfApproval;
+  }
+
   public void setRefPatterns(List<String> refPatterns) {
-    this.refPatterns = refPatterns;
+    if (refPatterns != null) {
+      this.refPatterns =
+          refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
+    } else {
+      this.refPatterns = null;
+    }
   }
 
   public List<LabelValue> getValues() {
@@ -281,36 +292,13 @@
   }
 
   public LabelValue getValue(short value) {
-    initByValue();
     return byValue.get(value);
   }
 
   public LabelValue getValue(PatchSetApproval ca) {
-    initByValue();
     return byValue.get(ca.getValue());
   }
 
-  private void initByValue() {
-    if (byValue == null) {
-      byValue = new HashMap<>();
-      for (LabelValue v : values) {
-        byValue.put(v.getValue(), v);
-      }
-    }
-  }
-
-  public List<Integer> getValuesAsList() {
-    if (intList == null) {
-      intList = new ArrayList<>(values.size());
-      for (LabelValue v : values) {
-        intList.add(Integer.valueOf(v.getValue()));
-      }
-      Collections.sort(intList);
-      Collections.reverse(intList);
-    }
-    return intList;
-  }
-
   public LabelId getLabelId() {
     return new LabelId(name);
   }
diff --git a/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
index 811e751..c0ba781 100644
--- a/java/com/google/gerrit/common/data/LabelValue.java
+++ b/java/com/google/gerrit/common/data/LabelValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import java.util.Objects;
+
 public class LabelValue {
   public static String formatValue(short value) {
     if (value < 0) {
@@ -56,6 +58,20 @@
   }
 
   @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof LabelValue)) {
+      return false;
+    }
+    LabelValue v = (LabelValue) o;
+    return value == v.value && Objects.equals(text, v.text);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value, text);
+  }
+
+  @Override
   public String toString() {
     return format();
   }
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index dff30d7..de6108e 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -27,6 +27,7 @@
   public static final String CREATE_SIGNED_TAG = "createSignedTag";
   public static final String CREATE_TAG = "createTag";
   public static final String DELETE = "delete";
+  public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
   public static final String EDIT_ASSIGNEE = "editAssignee";
   public static final String EDIT_HASHTAGS = "editHashtags";
@@ -58,6 +59,7 @@
     NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
     NAMES_LC.add(CREATE_TAG.toLowerCase());
     NAMES_LC.add(DELETE.toLowerCase());
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
@@ -155,13 +157,16 @@
     exclusiveGroup = newExclusiveGroup;
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<PermissionRule> getRules() {
-    initRules();
-    return rules;
+    if (rules == null) {
+      return new ArrayList<>();
+    }
+    return new ArrayList<>(rules);
   }
 
   public void setRules(List<PermissionRule> list) {
-    rules = list;
+    rules = new ArrayList<>(list);
   }
 
   public void add(PermissionRule rule) {
@@ -181,6 +186,12 @@
     }
   }
 
+  public void clearRules() {
+    if (rules != null) {
+      rules.clear();
+    }
+  }
+
   public PermissionRule getRule(GroupReference group) {
     return getRule(group, false);
   }
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
index c50af5c..8ab0a55 100644
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ b/java/com/google/gerrit/common/data/PermissionRule.java
@@ -66,27 +66,27 @@
     action = Action.BLOCK;
   }
 
-  public Boolean getForce() {
+  public boolean getForce() {
     return force;
   }
 
-  public void setForce(Boolean newForce) {
+  public void setForce(boolean newForce) {
     force = newForce;
   }
 
-  public Integer getMin() {
+  public int getMin() {
     return min;
   }
 
-  public void setMin(Integer min) {
+  public void setMin(int min) {
     this.min = min;
   }
 
-  public void setMax(Integer max) {
+  public void setMax(int max) {
     this.max = max;
   }
 
-  public Integer getMax() {
+  public int getMax() {
     return max;
   }
 
@@ -266,7 +266,7 @@
   }
 
   public boolean hasRange() {
-    return (!(getMin() == null || getMin() == 0)) || (!(getMax() == null || getMax() == 0));
+    return getMin() != 0 || getMax() != 0;
   }
 
   public static int parseInt(String value) {
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 0ccd820..2c1c93a 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
@@ -70,6 +71,7 @@
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.ContentType;
 import org.apache.http.nio.entity.NStringEntity;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
@@ -79,6 +81,7 @@
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
   protected static final String SEARCH = "_search";
+  protected static final String SETTINGS = "settings";
 
   protected static <T> List<T> decodeProtos(
       JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
@@ -96,7 +99,7 @@
     String content = "";
     if (responseEntity != null) {
       InputStream contentStream = responseEntity.getContent();
-      try (Reader reader = new InputStreamReader(contentStream)) {
+      try (Reader reader = new InputStreamReader(contentStream, UTF_8)) {
         content = CharStreams.toString(reader);
       }
     }
@@ -118,7 +121,8 @@
       SitePaths sitePaths,
       Schema<V> schema,
       ElasticRestClientProvider client,
-      String indexName) {
+      String indexName,
+      String indexType) {
     this.sitePaths = sitePaths;
     this.schema = schema;
     this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
@@ -126,7 +130,16 @@
     this.indexName = cfg.getIndexName(indexName, schema.getVersion());
     this.indexNameRaw = indexName;
     this.client = client;
-    this.type = client.adapter().getType(indexName);
+    this.type = client.adapter().getType(indexType);
+  }
+
+  AbstractElasticIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Schema<V> schema,
+      ElasticRestClientProvider client,
+      String indexName) {
+    this(cfg, sitePaths, schema, client, indexName, indexName);
   }
 
   @Override
@@ -159,10 +172,10 @@
   public void deleteAll() throws IOException {
     // Delete the index, if it exists.
     String endpoint = indexName + client.adapter().indicesExistParam();
-    Response response = client.get().performRequest("HEAD", endpoint);
+    Response response = client.get().performRequest(new Request("HEAD", endpoint));
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode == HttpStatus.SC_OK) {
-      response = client.get().performRequest("DELETE", indexName);
+      response = client.get().performRequest(new Request("DELETE", indexName));
       statusCode = response.getStatusLine().getStatusCode();
       if (statusCode != HttpStatus.SC_OK) {
         throw new IOException(
@@ -171,7 +184,8 @@
     }
 
     // Recreate the index.
-    response = performRequest("PUT", getMappings(), indexName, Collections.emptyMap());
+    String indexCreationFields = concatJsonString(getSettings(), getMappings());
+    response = performRequest("PUT", indexCreationFields, indexName, Collections.emptyMap());
     statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
       String error = String.format("Failed to create index %s: %s", indexName, statusCode);
@@ -183,6 +197,10 @@
 
   protected abstract String getMappings();
 
+  private String getSettings() {
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting()));
+  }
+
   protected abstract String getId(V v);
 
   protected String getMappingsForSingleType(String candidateType, MappingProperties properties) {
@@ -284,11 +302,19 @@
     return performRequest("POST", payload, uri, params);
   }
 
+  private String concatJsonString(String target, String addition) {
+    return target.substring(0, target.length() - 1) + "," + addition.substring(1);
+  }
+
   private Response performRequest(
       String method, Object payload, String uri, Map<String, String> params) throws IOException {
+    Request request = new Request(method, uri);
     String payloadStr = payload instanceof String ? (String) payload : payload.toString();
-    HttpEntity entity = new NStringEntity(payloadStr, ContentType.APPLICATION_JSON);
-    return client.get().performRequest(method, uri, params, entity);
+    request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
+    for (Map.Entry<String, String> entry : params.entrySet()) {
+      request.addParameter(entry.getKey(), entry.getValue());
+    }
+    return client.get().performRequest(request);
   }
 
   protected class ElasticQuerySource implements DataSource<V> {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index 58f4fb9..d18af42 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -46,14 +46,14 @@
 public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
     implements AccountIndex {
   static class AccountMapping {
-    MappingProperties accounts;
+    final MappingProperties accounts;
 
     AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
       this.accounts = ElasticMapping.createMapping(schema, adapter);
     }
   }
 
-  static final String ACCOUNTS = "accounts";
+  private static final String ACCOUNTS = "accounts";
 
   private final AccountMapping mapping;
   private final Provider<AccountCache> accountCache;
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 1ec8d2b..f6af79f 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -76,9 +76,9 @@
 class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
     implements ChangeIndex {
   static class ChangeMapping {
-    MappingProperties changes;
-    MappingProperties openChanges;
-    MappingProperties closedChanges;
+    final MappingProperties changes;
+    final MappingProperties openChanges;
+    final MappingProperties closedChanges;
 
     ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
       MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
@@ -88,9 +88,9 @@
     }
   }
 
-  static final String CHANGES = "changes";
-  static final String OPEN_CHANGES = "open_" + CHANGES;
-  static final String CLOSED_CHANGES = "closed_" + CHANGES;
+  private static final String CHANGES = "changes";
+  private static final String OPEN_CHANGES = "open_" + CHANGES;
+  private static final String CLOSED_CHANGES = "closed_" + CHANGES;
 
   private final ChangeMapping mapping;
   private final Provider<ReviewDb> db;
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 84dae7f..8d29d21 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -14,81 +14,93 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.common.base.MoreObjects;
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
 class ElasticConfiguration {
-  private static final String DEFAULT_HOST = "localhost";
-  private static final String DEFAULT_PORT = "9200";
-  private static final String DEFAULT_PROTOCOL = "http";
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  static final String SECTION_ELASTICSEARCH = "elasticsearch";
+  static final String KEY_PASSWORD = "password";
+  static final String KEY_USERNAME = "username";
+  static final String KEY_MAX_RETRY_TIMEOUT = "maxRetryTimeout";
+  static final String KEY_PREFIX = "prefix";
+  static final String KEY_SERVER = "server";
+  static final String DEFAULT_PORT = "9200";
+  static final String DEFAULT_USERNAME = "elastic";
+  static final int DEFAULT_MAX_RETRY_TIMEOUT_MS = 30000;
+  static final TimeUnit MAX_RETRY_TIMEOUT_UNIT = TimeUnit.MILLISECONDS;
 
   private final Config cfg;
+  private final List<HttpHost> hosts;
 
-  final List<HttpHost> urls;
   final String username;
   final String password;
-  final boolean requestCompression;
-  final long connectionTimeout;
-  final long maxConnectionIdleTime;
-  final TimeUnit maxConnectionIdleUnit = TimeUnit.MILLISECONDS;
-  final int maxTotalConnection;
-  final int readTimeout;
+  final int maxRetryTimeout;
   final String prefix;
 
   @Inject
   ElasticConfiguration(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
-    this.username = cfg.getString("elasticsearch", null, "username");
-    this.password = cfg.getString("elasticsearch", null, "password");
-    this.requestCompression = cfg.getBoolean("elasticsearch", null, "requestCompression", false);
-    this.connectionTimeout =
-        cfg.getTimeUnit("elasticsearch", null, "connectionTimeout", 3000, TimeUnit.MILLISECONDS);
-    this.maxConnectionIdleTime =
-        cfg.getTimeUnit(
-            "elasticsearch", null, "maxConnectionIdleTime", 3000, TimeUnit.MILLISECONDS);
-    this.maxTotalConnection = cfg.getInt("elasticsearch", null, "maxTotalConnection", 1);
-    this.readTimeout =
-        (int) cfg.getTimeUnit("elasticsearch", null, "readTimeout", 3000, TimeUnit.MICROSECONDS);
-    this.prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
-
-    Set<String> subsections = cfg.getSubsections("elasticsearch");
-    if (subsections.isEmpty()) {
-      HttpHost httpHost =
-          new HttpHost(DEFAULT_HOST, Integer.valueOf(DEFAULT_PORT), DEFAULT_PROTOCOL);
-      this.urls = Collections.singletonList(httpHost);
-    } else {
-      this.urls = new ArrayList<>(subsections.size());
-      for (String subsection : subsections) {
-        String port = getString(cfg, subsection, "port", DEFAULT_PORT);
-        String host = getString(cfg, subsection, "hostname", DEFAULT_HOST);
-        String protocol = getString(cfg, subsection, "protocol", DEFAULT_PROTOCOL);
-
-        HttpHost httpHost = new HttpHost(host, Integer.valueOf(port), protocol);
-        this.urls.add(httpHost);
+    this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
+    this.username =
+        password == null
+            ? null
+            : firstNonNull(
+                cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
+    this.maxRetryTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_MAX_RETRY_TIMEOUT,
+                DEFAULT_MAX_RETRY_TIMEOUT_MS,
+                MAX_RETRY_TIMEOUT_UNIT);
+    this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
+    this.hosts = new ArrayList<>();
+    for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
+      try {
+        URI uri = new URI(server);
+        int port = uri.getPort();
+        HttpHost httpHost =
+            new HttpHost(
+                uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
+        this.hosts.add(httpHost);
+      } catch (URISyntaxException | IllegalArgumentException e) {
+        logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage());
       }
     }
+
+    if (hosts.isEmpty()) {
+      throw new ProvisionException("No valid Elasticsearch servers configured");
+    }
+
+    logger.atInfo().log("Elasticsearch servers: %s", hosts);
   }
 
-  public Config getConfig() {
+  Config getConfig() {
     return cfg;
   }
 
-  public String getIndexName(String name, int schemaVersion) {
-    return String.format("%s%s_%04d", prefix, name, schemaVersion);
+  HttpHost[] getHosts() {
+    return hosts.toArray(new HttpHost[hosts.size()]);
   }
 
-  private String getString(Config cfg, String subsection, String name, String defaultValue) {
-    return MoreObjects.firstNonNull(cfg.getString("elasticsearch", subsection, name), defaultValue);
+  String getIndexName(String name, int schemaVersion) {
+    return String.format("%s%s_%04d", prefix, name, schemaVersion);
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index cf1a4ed..bf6b962 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -44,14 +44,14 @@
 public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
     implements GroupIndex {
   static class GroupMapping {
-    MappingProperties groups;
+    final MappingProperties groups;
 
     GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
       this.groups = ElasticMapping.createMapping(schema, adapter);
     }
   }
 
-  static final String GROUPS = "groups";
+  private static final String GROUPS = "groups";
 
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
index 3314a38..a777f47 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
@@ -16,18 +16,21 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gson.JsonParser;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 import org.apache.http.HttpStatus;
-import org.apache.http.client.methods.HttpGet;
+import org.apache.http.StatusLine;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 
 @Singleton
 class ElasticIndexVersionDiscovery {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ElasticRestClientProvider client;
 
   @Inject
@@ -37,17 +40,25 @@
 
   List<String> discover(String prefix, String indexName) throws IOException {
     String name = prefix + indexName + "_";
-    Response response = client.get().performRequest(HttpGet.METHOD_NAME, name + "*/_aliases");
+    Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name));
+    Response response = client.get().performRequest(request);
 
-    if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
-      return new JsonParser()
-          .parse(AbstractElasticIndex.getContent(response))
-          .getAsJsonObject()
-          .entrySet()
-          .stream()
-          .map(e -> e.getKey().replace(name, ""))
-          .collect(toList());
+    StatusLine statusLine = response.getStatusLine();
+    if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
+      String message =
+          String.format(
+              "Failed to discover index versions for %s: %d: %s",
+              name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
+      logger.atSevere().log(message);
+      throw new IOException(message);
     }
-    return Collections.emptyList();
+
+    return new JsonParser()
+        .parse(AbstractElasticIndex.getContent(response))
+        .getAsJsonObject()
+        .entrySet()
+        .stream()
+        .map(e -> e.getKey().replace(name, ""))
+        .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
index 58272f7..8011efa 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -30,6 +30,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.List;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Config;
 
@@ -57,13 +58,15 @@
       IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
     TreeMap<Integer, Version<V>> versions = new TreeMap<>();
     try {
-      for (String version : versionDiscovery.discover(prefix, def.getName())) {
+      List<String> discovered = versionDiscovery.discover(prefix, def.getName());
+      logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered);
+      for (String version : discovered) {
         Integer v = Ints.tryParse(version);
         if (v == null || version.length() != 4) {
           logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version);
           continue;
         }
-        versions.put(v, new Version<V>(null, v, true, cfg.getReady(def.getName(), v)));
+        versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
       }
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName());
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index a30e546..f8c4168 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -34,9 +34,9 @@
           || fieldType == FieldType.INTEGER_RANGE
           || fieldType == FieldType.LONG) {
         mapping.addNumber(name);
-      } else if (fieldType == FieldType.PREFIX
-          || fieldType == FieldType.FULL_TEXT
-          || fieldType == FieldType.STORED_ONLY) {
+      } else if (fieldType == FieldType.FULL_TEXT) {
+        mapping.addStringWithAnalyzer(name);
+      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
         mapping.addString(name);
       } else {
         throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
@@ -88,6 +88,13 @@
       return this;
     }
 
+    Builder addStringWithAnalyzer(String name) {
+      FieldProperties key = new FieldProperties(adapter.stringFieldType());
+      key.analyzer = "custom_with_char_filter";
+      fields.put(name, key);
+      return this;
+    }
+
     Builder add(String name, String type) {
       fields.put(name, new FieldProperties(type));
       return this;
@@ -102,6 +109,7 @@
     String type;
     String index;
     String format;
+    String analyzer;
     Map<String, FieldProperties> fields;
 
     FieldProperties(String type) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index 3201289..8cb69e0 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -28,14 +28,18 @@
   private final String stringFieldType;
   private final String indexProperty;
   private final String rawFieldsKey;
+  private final String versionDiscoveryUrl;
 
   ElasticQueryAdapter(ElasticVersion version) {
     this.ignoreUnmapped = version == ElasticVersion.V2_4;
-    this.usePostV5Type = version == ElasticVersion.V6_2;
+    this.usePostV5Type = version.isV6();
+    this.versionDiscoveryUrl = version.isV6() ? "%s*" : "%s*/_aliases";
 
     switch (version) {
       case V5_6:
       case V6_2:
+      case V6_3:
+      case V6_4:
         this.searchFilteringName = "_source";
         this.indicesExistParam = "?allow_no_indices=false";
         this.exactFieldType = "keyword";
@@ -98,4 +102,8 @@
   String getType(String preV6Type) {
     return usePostV5Type() ? POST_V5_TYPE : preV6Type;
   }
+
+  String getVersionDiscoveryUrl(String name) {
+    return String.format(versionDiscoveryUrl, name);
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index 2a97e2e..394158d 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -34,7 +34,7 @@
 
 public class ElasticQueryBuilder {
 
-  protected <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
+  <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
     if (p instanceof AndPredicate) {
       return and(p);
     } else if (p instanceof OrPredicate) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index c2c4548..337f2ca 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -22,7 +22,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.apache.http.HttpHost;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
 import org.apache.http.auth.AuthScope;
@@ -30,6 +29,7 @@
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 import org.elasticsearch.client.RestClient;
 import org.elasticsearch.client.RestClientBuilder;
@@ -38,18 +38,14 @@
 class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final HttpHost[] hosts;
-  private final String username;
-  private final String password;
+  private final ElasticConfiguration cfg;
 
-  private RestClient client;
+  private volatile RestClient client;
   private ElasticQueryAdapter adapter;
 
   @Inject
   ElasticRestClientProvider(ElasticConfiguration cfg) {
-    hosts = cfg.urls.toArray(new HttpHost[cfg.urls.size()]);
-    username = cfg.username;
-    password = cfg.password;
+    this.cfg = cfg;
   }
 
   public static LifecycleModule module() {
@@ -110,7 +106,7 @@
 
   private ElasticVersion getVersion() throws ElasticException {
     try {
-      Response response = client.performRequest("GET", "");
+      Response response = client.performRequest(new Request("GET", ""));
       StatusLine statusLine = response.getStatusLine();
       if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
         throw new FailedToGetVersion(statusLine);
@@ -131,12 +127,15 @@
   }
 
   private RestClient build() {
-    RestClientBuilder builder = RestClient.builder(hosts);
+    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    builder.setMaxRetryTimeoutMillis(cfg.maxRetryTimeout);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
+    String username = cfg.username;
+    String password = cfg.password;
     if (username != null && password != null) {
       CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
       credentialsProvider.setCredentials(
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
new file mode 100644
index 0000000..6fd234d
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2018 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 com.google.common.collect.ImmutableMap;
+import java.util.Map;
+
+class ElasticSetting {
+  /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */
+  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
+
+  static SettingProperties createSetting() {
+    ElasticSetting.Builder settings = new ElasticSetting.Builder();
+    settings.addCharFilter();
+    settings.addAnalyzer();
+    return settings.build();
+  }
+
+  static class Builder {
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    SettingProperties build() {
+      SettingProperties properties = new SettingProperties();
+      properties.analysis = fields.build();
+      return properties;
+    }
+
+    void addCharFilter() {
+      FieldProperties charMapping = new FieldProperties("mapping");
+      charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
+
+      FieldProperties charFilter = new FieldProperties();
+      charFilter.customMapping = charMapping;
+      fields.put("char_filter", charFilter);
+    }
+
+    void addAnalyzer() {
+      FieldProperties customAnalyzer = new FieldProperties("custom");
+      customAnalyzer.tokenizer = "standard";
+      customAnalyzer.charFilter = new String[] {"custom_mapping"};
+      customAnalyzer.filter = new String[] {"lowercase"};
+
+      FieldProperties analyzer = new FieldProperties();
+      analyzer.customWithCharFilter = customAnalyzer;
+      fields.put("analyzer", analyzer);
+    }
+
+    private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
+      int mappingIndex = 0;
+      int numOfMappings = map.size();
+      String[] mapping = new String[numOfMappings];
+      for (Map.Entry<String, String> e : map.entrySet()) {
+        mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue();
+      }
+      return mapping;
+    }
+  }
+
+  static class SettingProperties {
+    Map<String, FieldProperties> analysis;
+  }
+
+  static class FieldProperties {
+    String tokenizer;
+    String type;
+    String[] charFilter;
+    String[] filter;
+    String[] mappings;
+    FieldProperties customMapping;
+    FieldProperties customWithCharFilter;
+
+    FieldProperties() {}
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index ff26382..dfa5d21 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -20,39 +20,45 @@
 public enum ElasticVersion {
   V2_4("2.4.*"),
   V5_6("5.6.*"),
-  V6_2("6.2.*");
+  V6_2("6.2.*"),
+  V6_3("6.3.*"),
+  V6_4("6.4.*");
 
   private final String version;
   private final Pattern pattern;
 
-  private ElasticVersion(String version) {
+  ElasticVersion(String version) {
     this.version = version;
     this.pattern = Pattern.compile(version);
   }
 
-  public static class InvalidVersion extends ElasticException {
+  public static class UnsupportedVersion extends ElasticException {
     private static final long serialVersionUID = 1L;
 
-    InvalidVersion(String version) {
+    UnsupportedVersion(String version) {
       super(
           String.format(
-              "Invalid version: [%s]. Supported versions: %s", version, supportedVersions()));
+              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
     }
   }
 
-  public static ElasticVersion forVersion(String version) throws InvalidVersion {
+  public static ElasticVersion forVersion(String version) throws UnsupportedVersion {
     for (ElasticVersion value : ElasticVersion.values()) {
       if (value.pattern.matcher(version).matches()) {
         return value;
       }
     }
-    throw new InvalidVersion(version);
+    throw new UnsupportedVersion(version);
   }
 
   public static String supportedVersions() {
     return Joiner.on(", ").join(ElasticVersion.values());
   }
 
+  public boolean isV6() {
+    return version.startsWith("6.");
+  }
+
   @Override
   public String toString() {
     return version;
diff --git a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
new file mode 100644
index 0000000..aa31dd0
--- /dev/null
+++ b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2018 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.annotations;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for features that are deprecated, but still present to adhere to the one-release-grace
+ * period we promised to users.
+ */
+@Target({ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
+@Retention(SOURCE)
+@BindingAnnotation
+public @interface RemoveAfter {
+  /**
+   * Version after which the annotated functionality can be removed. Once the referenced version was
+   * branched off, the annotated code can be removed.
+   */
+  String value();
+}
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
index 133de31..02afbdc 100644
--- a/java/com/google/gerrit/extensions/api/access/GerritPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
@@ -18,7 +18,13 @@
 
 /** Gerrit permission for hosts, projects, refs, changes, labels and plugins. */
 public interface GerritPermission {
-  /** @return readable identifier of this permission for exception message. */
+  /**
+   * A description in the context of an exception message.
+   *
+   * <p>Should be grammatical when used in the construction "not permitted: [description] on
+   * [resource]", although individual {@code PermissionBackend} implementations may vary the
+   * wording.
+   */
   String describeForException();
 
   static String describeEnumValue(Enum<?> value) {
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 5d8e950..8273d84 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -29,6 +29,7 @@
   public Set<String> ownerOf;
   public Boolean canUpload;
   public Boolean canAdd;
+  public Boolean canAddTags;
   public Boolean configVisible;
   public Map<String, GroupInfo> groups;
   public List<WebLinkInfo> configWebLinks;
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 0c3a11f..b7fce5f 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
@@ -36,6 +37,8 @@
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
 
+  AccountDetailInfo detail() throws RestApiException;
+
   boolean getActive() throws RestApiException;
 
   void setActive(boolean active) throws RestApiException;
@@ -106,6 +109,9 @@
 
   void deleteExternalIds(List<String> externalIds) throws RestApiException;
 
+  List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+      throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -117,6 +123,11 @@
     }
 
     @Override
+    public AccountDetailInfo detail() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean getActive() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -293,5 +304,11 @@
     public void deleteExternalIds(List<String> externalIds) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/AgreementInput.java b/java/com/google/gerrit/extensions/api/accounts/AgreementInput.java
similarity index 94%
rename from java/com/google/gerrit/extensions/common/AgreementInput.java
rename to java/com/google/gerrit/extensions/api/accounts/AgreementInput.java
index 0c6cf2d..9f35eed 100644
--- a/java/com/google/gerrit/extensions/common/AgreementInput.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AgreementInput.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.api.accounts;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
diff --git a/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java b/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java
new file mode 100644
index 0000000..113f3d4
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/DeleteDraftCommentsInput.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2018 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.accounts;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DeleteDraftCommentsInput {
+  /**
+   * Delete comments only on changes that match this query.
+   *
+   * <p>If null or empty, delete comments on all changes.
+   */
+  @DefaultInput public String query;
+
+  public DeleteDraftCommentsInput() {
+    this(null);
+  }
+
+  public DeleteDraftCommentsInput(@Nullable String query) {
+    this.query = query;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java
similarity index 61%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java
index 13d2b9d..c15d5bc 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/api/accounts/DeletedDraftCommentInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.api.accounts;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import java.util.List;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+public class DeletedDraftCommentInfo {
+  public ChangeInfo change;
+  public List<CommentInfo> deleted;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
index eb2d2b9..66356f1 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
@@ -24,6 +24,14 @@
   ChangeMessageInfo get() throws RestApiException;
 
   /**
+   * Deletes a change message by replacing its message. For NoteDb, it's implemented by rewriting
+   * the commit history of change meta branch.
+   *
+   * @return the change message with its message updated.
+   */
+  ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -32,5 +40,10 @@
     public ChangeMessageInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java
new file mode 100644
index 0000000..ece5a61
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteChangeMessageInput.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 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.restapi.DefaultInput;
+
+/** Input for deleting a change message. */
+public class DeleteChangeMessageInput {
+  /** An optional reason for deleting the change message. */
+  @DefaultInput public String reason;
+
+  public DeleteChangeMessageInput() {
+    this.reason = "";
+  }
+
+  public DeleteChangeMessageInput(String reason) {
+    this.reason = reason;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java b/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
index d876034..8fe47bd 100644
--- a/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -23,9 +24,11 @@
   public Map<String, Collection<String>> external;
 
   public IncludedInInfo(
-      List<String> branches, List<String> tags, Map<String, Collection<String>> external) {
-    this.branches = branches;
-    this.tags = tags;
+      Collection<String> branches,
+      Collection<String> tags,
+      Map<String, Collection<String>> external) {
+    this.branches = new ArrayList<>(branches);
+    this.tags = new ArrayList<>(tags);
     this.external = external;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java
similarity index 70%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java
index 13d2b9d..f0ef07c 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/api/plugins/InstallPluginInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.api.plugins;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.RawInput;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+public class InstallPluginInput {
+  public @DefaultInput String url;
+  public RawInput raw;
 }
diff --git a/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
index 2828db5..6c2d6db 100644
--- a/java/com/google/gerrit/extensions/api/plugins/Plugins.java
+++ b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.extensions.api.plugins;
 
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,6 +28,10 @@
 
   PluginApi name(String name) throws RestApiException;
 
+  @Deprecated
+  PluginApi install(String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+      throws RestApiException;
+
   PluginApi install(String name, InstallPluginInput input) throws RestApiException;
 
   abstract class ListRequest {
@@ -121,7 +124,15 @@
     }
 
     @Override
-    public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
+    @Deprecated
+    public PluginApi install(
+        String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PluginApi install(String name, InstallPluginInput input) {
       throw new NotImplementedException();
     }
   }
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
new file mode 100644
index 0000000..145b200
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2018 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.projects;
+
+public class CheckProjectInput {
+  public AutoCloseableChangesCheckInput autoCloseableChangesCheck;
+
+  public static class AutoCloseableChangesCheckInput {
+    /** Whether auto-closeable changes should be fixed by setting their status to MERGED. */
+    public Boolean fix;
+
+    /** Branch that should be checked for auto-closeable changes. */
+    public String branch;
+
+    /** Number of commits to skip. */
+    public Integer skipCommits;
+
+    /** Maximum number of commits to walk. */
+    public Integer maxCommits;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
new file mode 100644
index 0000000..e685122
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 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.projects;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import java.util.List;
+
+public class CheckProjectResultInfo {
+  public AutoCloseableChangesCheckResult autoCloseableChangesCheckResult;
+
+  public static class AutoCloseableChangesCheckResult {
+    public List<ChangeInfo> autoCloseableChanges;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 80115aa..08ba486 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -33,6 +34,7 @@
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
   public InheritedBooleanInfo privateByDefault;
+  public InheritedBooleanInfo workInProgressByDefault;
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
@@ -57,9 +59,17 @@
   }
 
   public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
+    /** The effective value in bytes. Null if not set. */
+    @Nullable public String value;
+
+    /** The value configured explicitly on the project as a formatted string. Null if not set. */
+    @Nullable public String configuredValue;
+
+    /**
+     * Whether the value was inherited or overridden from the project's parent hierarchy or global
+     * config. Null if not inherited or overridden.
+     */
+    @Nullable public String summary;
   }
 
   public static class ConfigParameterInfo {
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 37a2e8b..1a6d77b 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -30,6 +30,7 @@
   public InheritableBoolean requireSignedPush;
   public InheritableBoolean rejectImplicitMerges;
   public InheritableBoolean privateByDefault;
+  public InheritableBoolean workInProgressByDefault;
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
diff --git a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
similarity index 73%
copy from resources/com/google/gerrit/pgm/ProtoGenHeader.txt
copy to java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
index a380955..1b962c1 100644
--- a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
+++ b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2011 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-syntax = "proto2";
+package com.google.gerrit.extensions.api.projects;
 
-option java_api_version = 2;
-option java_package = "com.google.gerrit.proto.reviewdb";
-
-package devtools.gerritcodereview;
+public class IndexProjectInput {
+  public Boolean indexChildren;
+  public Boolean async;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index c9f47c2..0139b52 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -43,6 +43,8 @@
 
   AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
 
+  CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException;
+
   ConfigInfo config() throws RestApiException;
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
@@ -191,6 +193,13 @@
   void parent(String parent) throws RestApiException;
 
   /**
+   * Reindex the project and children in case {@code indexChildren} is specified.
+   *
+   * @param indexChildren decides if children should be indexed recursively
+   */
+  void index(boolean indexChildren) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -236,6 +245,11 @@
     }
 
     @Override
+    public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ConfigInfo config() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -344,5 +358,10 @@
     public void parent(String parent) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void index(boolean indexChildren) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
similarity index 93%
rename from java/com/google/gerrit/extensions/common/SetDashboardInput.java
rename to java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
index 13d2b9d..0083c0e 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 3307997..3bca4bb 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -44,10 +44,11 @@
             .thenComparingInt(range -> range.endLine)
             .thenComparingInt(range -> range.endCharacter);
 
-    public int startLine; // 1-based, inclusive
-    public int startCharacter; // 0-based, inclusive
-    public int endLine; // 1-based, exclusive
-    public int endCharacter; // 0-based, exclusive
+    // Start position is inclusive; end position is exclusive.
+    public int startLine; // 1-based
+    public int startCharacter; // 0-based
+    public int endLine; // 1-based
+    public int endCharacter; // 0-based
 
     public boolean isValid() {
       return startLine > 0
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 9dcba5e..1f16d8d 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -158,6 +158,7 @@
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean workInProgressByDefault;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -227,6 +228,7 @@
     p.signedOffBy = false;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.workInProgressByDefault = false;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
similarity index 70%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index 13d2b9d..a498ab0 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -14,9 +14,13 @@
 
 package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.sql.Timestamp;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+public class AccountDetailInfo extends AccountInfo {
+  public Timestamp registeredOn;
+  public Boolean inactive;
+
+  public AccountDetailInfo(Integer id) {
+    super(id);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index a2e61b8..07ad71b 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -45,4 +45,24 @@
   public int hashCode() {
     return Objects.hash(id, tag, author, realAuthor, date, message, _revisionNumber);
   }
+
+  @Override
+  public String toString() {
+    return "ChangeMessageInfo{"
+        + "id="
+        + id
+        + ", tag="
+        + tag
+        + ", author="
+        + author
+        + ", realAuthor="
+        + realAuthor
+        + ", date="
+        + date
+        + ", _revisionNumber"
+        + _revisionNumber
+        + ", message=["
+        + message
+        + "]}";
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index a812908..32c5bd5 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class FileInfo {
   public Character status;
   public Boolean binary;
@@ -22,4 +24,24 @@
   public Integer linesDeleted;
   public long sizeDelta;
   public long size;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FileInfo) {
+      FileInfo fileInfo = (FileInfo) o;
+      return Objects.equals(status, fileInfo.status)
+          && Objects.equals(binary, fileInfo.binary)
+          && Objects.equals(oldPath, fileInfo.oldPath)
+          && Objects.equals(linesInserted, fileInfo.linesInserted)
+          && Objects.equals(linesDeleted, fileInfo.linesDeleted)
+          && Objects.equals(sizeDelta, fileInfo.sizeDelta)
+          && Objects.equals(size, fileInfo.size);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/InstallPluginInput.java b/java/com/google/gerrit/extensions/common/InstallPluginInput.java
index 4774ae7..9cefb0f 100644
--- a/java/com/google/gerrit/extensions/common/InstallPluginInput.java
+++ b/java/com/google/gerrit/extensions/common/InstallPluginInput.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.RawInput;
 
+/** @deprecated use {@link com.google.gerrit.extensions.api.plugins.InstallPluginInput}. */
+@Deprecated
 public class InstallPluginInput {
   public @DefaultInput String url;
   public RawInput raw;
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
index a940403..53f0375 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.base.MoreObjects;
 import java.util.Map;
 import java.util.Objects;
 
@@ -50,4 +51,14 @@
   public int hashCode() {
     return Objects.hash(status, fallbackText, type, data);
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("status", status)
+        .add("fallbackText", fallbackText)
+        .add("type", type)
+        .add("data", data)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 9030a1c..5fc8ba6 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
@@ -81,4 +82,10 @@
     ContentEntry contentEntry = actual();
     return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
   }
+
+  public IntegerSubject numberOfSkippedLines() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.skip).named("number of skipped lines");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index 6918325..057a1a2 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -47,4 +47,16 @@
     DiffInfo diffInfo = actual();
     return Truth.assertThat(diffInfo.changeType).named("changeType");
   }
+
+  public FileMetaSubject metaA() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return FileMetaSubject.assertThat(diffInfo.metaA).named("metaA");
+  }
+
+  public FileMetaSubject metaB() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return FileMetaSubject.assertThat(diffInfo.metaB).named("metaB");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
new file mode 100644
index 0000000..e77eef1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 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.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
+
+public class FileMetaSubject extends Subject<FileMetaSubject, FileMeta> {
+
+  public static FileMetaSubject assertThat(FileMeta fileMeta) {
+    return assertAbout(FileMetaSubject::new).that(fileMeta);
+  }
+
+  private FileMetaSubject(FailureMetadata failureMetadata, FileMeta fileMeta) {
+    super(failureMetadata, fileMeta);
+  }
+
+  public IntegerSubject totalLineCount() {
+    isNotNull();
+    FileMeta fileMeta = actual();
+    return Truth.assertThat(fileMeta.lines).named("total line count");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
index b478a7e..db7f0d1 100644
--- a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common.testing;
 
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
@@ -51,14 +52,14 @@
   public void isValid() {
     isNotNull();
     if (!actual().isValid()) {
-      fail("is valid");
+      failWithoutActual(fact("expected", "valid"));
     }
   }
 
   public void isInvalid() {
     isNotNull();
     if (actual().isValid()) {
-      fail("is invalid");
+      failWithoutActual(fact("expected", "not valid"));
     }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
similarity index 61%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
index 13d2b9d..70014f3 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+/** Notified whenever a Change is deleted. */
+@ExtensionPoint
+public interface ChangeDeletedListener {
+  interface Event extends ChangeEvent {}
+
+  void onChangeDeleted(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
index e46ceb8..2da6ec9 100644
--- a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface PrivateStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onPrivateStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
index e957421..d0e2bc1 100644
--- a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface WorkInProgressStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onWorkInProgressStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 477b666..4f36ab4 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -34,17 +35,6 @@
  * exception is thrown.
  */
 public class DynamicItem<T> {
-  /** Pair of provider implementation and plugin providing it. */
-  static class NamedProvider<T> {
-    final Provider<T> impl;
-    final String pluginName;
-
-    NamedProvider(Provider<T> provider, String pluginName) {
-      this.impl = provider;
-      this.pluginName = pluginName;
-    }
-  }
-
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
    *
@@ -88,7 +78,8 @@
    * @param item item to store.
    */
   public static <T> DynamicItem<T> itemOf(Class<T> member, T item) {
-    return new DynamicItem<>(keyFor(TypeLiteral.get(member)), Providers.of(item), "gerrit");
+    return new DynamicItem<>(
+        keyFor(TypeLiteral.get(member)), Providers.of(item), PluginName.GERRIT);
   }
 
   @SuppressWarnings("unchecked")
@@ -137,12 +128,26 @@
    * @return the configured item instance; null if no implementation has been bound to the item.
    *     This is common if no plugin registered an implementation for the type.
    */
+  @Nullable
   public T get() {
     NamedProvider<T> item = ref.get();
     return item != null ? item.impl.get() : null;
   }
 
   /**
+   * Get the name of the plugin that has bound the configured item, or null.
+   *
+   * @return the name of the plugin that has bound the configured item; null if no implementation
+   *     has been bound to the item. This is common if no plugin registered an implementation for
+   *     the type.
+   */
+  @Nullable
+  public String getPluginName() {
+    NamedProvider<T> item = ref.get();
+    return item != null ? item.pluginName : null;
+  }
+
+  /**
    * Set the element to provide.
    *
    * @param item the item to use. Must not be null.
@@ -165,7 +170,7 @@
     NamedProvider<T> old = null;
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
-      if (old != null && !"gerrit".equals(old.pluginName)) {
+      if (old != null && !PluginName.GERRIT.equals(old.pluginName)) {
         throw new ProvisionException(
             String.format(
                 "%s already provided by %s, ignoring plugin %s",
@@ -197,7 +202,9 @@
     NamedProvider<T> old = null;
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
-      if (old != null && !"gerrit".equals(old.pluginName) && !pluginName.equals(old.pluginName)) {
+      if (old != null
+          && !PluginName.GERRIT.equals(old.pluginName)
+          && !pluginName.equals(old.pluginName)) {
         // We allow to replace:
         // 1. Gerrit core items, e.g. websession cache
         //    can be replaced by plugin implementation
@@ -233,6 +240,7 @@
     }
 
     @Override
+    @Nullable
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       NamedProvider<T> n = new NamedProvider<>(newItem, item.pluginName);
       if (ref.compareAndSet(item, n)) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index 5b76741..d8dd1f9 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -36,7 +36,7 @@
 
   @Override
   public DynamicItem<T> get() {
-    return new DynamicItem<>(key, find(injector, type), "gerrit");
+    return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
   }
 
   private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index 7178a16..96d19b2 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -41,6 +41,28 @@
  * singleton and non-singleton members.
  */
 public abstract class DynamicMap<T> implements Iterable<DynamicMap.Entry<T>> {
+  public static class Entry<T> {
+    private final NamePair namePair;
+    private final Provider<T> provider;
+
+    private Entry(NamePair namePair, Provider<T> provider) {
+      this.namePair = namePair;
+      this.provider = provider;
+    }
+
+    public String getPluginName() {
+      return namePair.pluginName;
+    }
+
+    public String getExportName() {
+      return namePair.exportName;
+    }
+
+    public Provider<T> getProvider() {
+      return provider;
+    }
+  }
+
   /**
    * Declare a singleton {@code DynamicMap<T>} with a binder.
    *
@@ -154,23 +176,8 @@
 
       @Override
       public Entry<T> next() {
-        final Map.Entry<NamePair, Provider<T>> e = i.next();
-        return new Entry<T>() {
-          @Override
-          public String getPluginName() {
-            return e.getKey().pluginName;
-          }
-
-          @Override
-          public String getExportName() {
-            return e.getKey().exportName;
-          }
-
-          @Override
-          public Provider<T> getProvider() {
-            return e.getValue();
-          }
-        };
+        Map.Entry<NamePair, Provider<T>> e = i.next();
+        return new Entry<>(e.getKey(), e.getValue());
       }
 
       @Override
@@ -180,14 +187,6 @@
     };
   }
 
-  public interface Entry<T> {
-    String getPluginName();
-
-    String getExportName();
-
-    Provider<T> getProvider();
-  }
-
   static class NamePair {
     private final String pluginName;
     private final String exportName;
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java b/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
index 420a356..9d96131 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
@@ -37,7 +37,7 @@
     if (bindings != null) {
       for (Binding<T> b : bindings) {
         if (b.getKey().getAnnotation() != null) {
-          m.put("gerrit", b.getKey(), b.getProvider());
+          m.put(PluginName.GERRIT, b.getKey(), b.getProvider());
         }
       }
     }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 7ffb86d..6b3a49b 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -14,6 +14,12 @@
 
 package com.google.gerrit.extensions.registration;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.naturalOrder;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -39,6 +45,24 @@
  * singleton and non-singleton members.
  */
 public class DynamicSet<T> implements Iterable<T> {
+  public static class Entry<T> {
+    private final String pluginName;
+    private final Provider<T> provider;
+
+    private Entry(String pluginName, Provider<T> provider) {
+      this.pluginName = pluginName;
+      this.provider = provider;
+    }
+
+    public String getPluginName() {
+      return pluginName;
+    }
+
+    public Provider<T> getProvider() {
+      return provider;
+    }
+  }
+
   /**
    * Declare a singleton {@code DynamicSet<T>} with a binder.
    *
@@ -129,12 +153,12 @@
   }
 
   public static <T> DynamicSet<T> emptySet() {
-    return new DynamicSet<>(Collections.<AtomicReference<Provider<T>>>emptySet());
+    return new DynamicSet<>(Collections.<AtomicReference<NamedProvider<T>>>emptySet());
   }
 
-  private final CopyOnWriteArrayList<AtomicReference<Provider<T>>> items;
+  private final CopyOnWriteArrayList<AtomicReference<NamedProvider<T>>> items;
 
-  DynamicSet(Collection<AtomicReference<Provider<T>>> base) {
+  DynamicSet(Collection<AtomicReference<NamedProvider<T>>> base) {
     items = new CopyOnWriteArrayList<>(base);
   }
 
@@ -144,38 +168,59 @@
 
   @Override
   public Iterator<T> iterator() {
-    final Iterator<AtomicReference<Provider<T>>> itr = items.iterator();
+    Iterator<Entry<T>> entryIterator = entries().iterator();
     return new Iterator<T>() {
-      private T next;
-
       @Override
       public boolean hasNext() {
-        while (next == null && itr.hasNext()) {
-          Provider<T> p = itr.next().get();
-          if (p != null) {
-            try {
-              next = p.get();
-            } catch (RuntimeException e) {
-              // TODO Log failed member of DynamicSet.
-            }
-          }
-        }
-        return next != null;
+        return entryIterator.hasNext();
       }
 
       @Override
       public T next() {
-        if (hasNext()) {
-          T result = next;
-          next = null;
-          return result;
-        }
-        throw new NoSuchElementException();
+        Entry<T> next = entryIterator.next();
+        return next != null ? next.getProvider().get() : null;
       }
+    };
+  }
 
+  public Iterable<Entry<T>> entries() {
+    final Iterator<AtomicReference<NamedProvider<T>>> itr = items.iterator();
+    return new Iterable<Entry<T>>() {
       @Override
-      public void remove() {
-        throw new UnsupportedOperationException();
+      public Iterator<Entry<T>> iterator() {
+        return new Iterator<Entry<T>>() {
+          private Entry<T> next;
+
+          @Override
+          public boolean hasNext() {
+            while (next == null && itr.hasNext()) {
+              NamedProvider<T> p = itr.next().get();
+              if (p != null) {
+                try {
+                  next = new Entry<>(p.pluginName, p.impl);
+                } catch (RuntimeException e) {
+                  // TODO Log failed member of DynamicSet.
+                }
+              }
+            }
+            return next != null;
+          }
+
+          @Override
+          public Entry<T> next() {
+            if (hasNext()) {
+              Entry<T> result = next;
+              next = null;
+              return result;
+            }
+            throw new NoSuchElementException();
+          }
+
+          @Override
+          public void remove() {
+            throw new UnsupportedOperationException();
+          }
+        };
       }
     };
   }
@@ -198,13 +243,29 @@
   }
 
   /**
-   * Add one new element to the set.
+   * Get the names of all running plugins supplying this type.
    *
-   * @param item the item to add to the collection. Must not be null.
-   * @return handle to remove the item at a later point in time.
+   * @return sorted set of active plugins that supply at least one item.
    */
-  public RegistrationHandle add(T item) {
-    return add(Providers.of(item));
+  public ImmutableSortedSet<String> plugins() {
+    return items
+        .stream()
+        .map(i -> i.get().pluginName)
+        .collect(toImmutableSortedSet(naturalOrder()));
+  }
+
+  /**
+   * Get the items exported by a single plugin.
+   *
+   * @param pluginName name of the plugin.
+   * @return items exported by a plugin.
+   */
+  public ImmutableSet<Provider<T>> byPlugin(String pluginName) {
+    return items
+        .stream()
+        .filter(i -> i.get().pluginName.equals(pluginName))
+        .map(i -> i.get().impl)
+        .collect(toImmutableSet());
   }
 
   /**
@@ -213,13 +274,24 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle add(Provider<T> item) {
-    final AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
+  public RegistrationHandle add(String pluginName, T item) {
+    return add(pluginName, Providers.of(item));
+  }
+
+  /**
+   * Add one new element to the set.
+   *
+   * @param item the item to add to the collection. Must not be null.
+   * @return handle to remove the item at a later point in time.
+   */
+  public RegistrationHandle add(String pluginName, Provider<T> item) {
+    final AtomicReference<NamedProvider<T>> ref =
+        new AtomicReference<>(new NamedProvider<>(item, pluginName));
     items.add(ref);
     return new RegistrationHandle() {
       @Override
       public void remove() {
-        if (ref.compareAndSet(item, null)) {
+        if (ref.compareAndSet(ref.get(), null)) {
           items.remove(ref);
         }
       }
@@ -229,6 +301,7 @@
   /**
    * Add one new element that may be hot-replaceable in the future.
    *
+   * @param pluginName unique name of the plugin providing the item.
    * @param key unique description from the item's Guice binding. This can be later obtained from
    *     the registration handle to facilitate matching with the new equivalent instance during a
    *     hot reload.
@@ -236,18 +309,19 @@
    * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
    *     the collection.
    */
-  public ReloadableRegistrationHandle<T> add(Key<T> key, Provider<T> item) {
-    AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
+  public ReloadableRegistrationHandle<T> add(String pluginName, Key<T> key, Provider<T> item) {
+    AtomicReference<NamedProvider<T>> ref =
+        new AtomicReference<>(new NamedProvider<>(item, pluginName));
     items.add(ref);
-    return new ReloadableHandle(ref, key, item);
+    return new ReloadableHandle(ref, key, ref.get());
   }
 
   private class ReloadableHandle implements ReloadableRegistrationHandle<T> {
-    private final AtomicReference<Provider<T>> ref;
+    private final AtomicReference<NamedProvider<T>> ref;
     private final Key<T> key;
-    private final Provider<T> item;
+    private final NamedProvider<T> item;
 
-    ReloadableHandle(AtomicReference<Provider<T>> ref, Key<T> key, Provider<T> item) {
+    ReloadableHandle(AtomicReference<NamedProvider<T>> ref, Key<T> key, NamedProvider<T> item) {
       this.ref = ref;
       this.key = key;
       this.item = item;
@@ -267,8 +341,9 @@
 
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
-      if (ref.compareAndSet(item, newItem)) {
-        return new ReloadableHandle(ref, newKey, newItem);
+      NamedProvider<T> n = new NamedProvider<>(newItem, item.pluginName);
+      if (ref.compareAndSet(item, n)) {
+        return new ReloadableHandle(ref, newKey, n);
       }
       return null;
     }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index 707c76a..6d36f54 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -38,16 +38,17 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Provider<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> List<AtomicReference<NamedProvider<T>>> find(
+      Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     int cnt = bindings != null ? bindings.size() : 0;
     if (cnt == 0) {
       return Collections.emptyList();
     }
-    List<AtomicReference<Provider<T>>> r = new ArrayList<>(cnt);
+    List<AtomicReference<NamedProvider<T>>> r = new ArrayList<>(cnt);
     for (Binding<T> b : bindings) {
       if (b.getKey().getAnnotation() != null) {
-        r.add(new AtomicReference<>(b.getProvider()));
+        r.add(new AtomicReference<>(new NamedProvider<>(b.getProvider(), PluginName.GERRIT)));
       }
     }
     return r;
diff --git a/java/com/google/gerrit/extensions/registration/NamedProvider.java b/java/com/google/gerrit/extensions/registration/NamedProvider.java
new file mode 100644
index 0000000..aca651b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/registration/NamedProvider.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 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.registration;
+
+import com.google.inject.Provider;
+
+/** Pair of provider implementation and plugin providing it. */
+class NamedProvider<T> {
+  final Provider<T> impl;
+  final String pluginName;
+
+  NamedProvider(Provider<T> provider, String pluginName) {
+    this.impl = provider;
+    this.pluginName = pluginName;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/registration/PluginName.java
similarity index 64%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/extensions/registration/PluginName.java
index 13d2b9d..c110d45 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/registration/PluginName.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.extensions.registration;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+public class PluginName {
+  /** Name that is used as plugin name if Gerrit core implements a plugin extension point. */
+  public static final String GERRIT = "gerrit";
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+  private PluginName() {}
 }
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index e606079..fd31fcd 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -81,7 +81,7 @@
   }
 
   public static List<RegistrationHandle> attachItems(
-      Injector src, Map<TypeLiteral<?>, DynamicItem<?>> items, String pluginName) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
       return Collections.emptyList();
     }
@@ -107,7 +107,7 @@
   }
 
   public static List<RegistrationHandle> attachSets(
-      Injector src, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
       return Collections.emptyList();
     }
@@ -123,7 +123,7 @@
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.add(b.getKey(), b.getProvider()));
+            handles.add(set.add(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -135,7 +135,7 @@
   }
 
   public static List<RegistrationHandle> attachMaps(
-      Injector src, String groupName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
       return Collections.emptyList();
     }
@@ -147,12 +147,12 @@
         TypeLiteral<Object> type = (TypeLiteral<Object>) e.getKey();
 
         @SuppressWarnings("unchecked")
-        PrivateInternals_DynamicMapImpl<Object> set =
+        PrivateInternals_DynamicMapImpl<Object> map =
             (PrivateInternals_DynamicMapImpl<Object>) e.getValue();
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.put(groupName, b.getKey(), b.getProvider()));
+            handles.add(map.put(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -174,8 +174,8 @@
         handles = new ArrayList<>(4);
         Injector parent = self.getParent();
         while (parent != null) {
-          handles.addAll(attachSets(self, dynamicSetsOf(parent)));
-          handles.addAll(attachMaps(self, "gerrit", dynamicMapsOf(parent)));
+          handles.addAll(attachSets(self, PluginName.GERRIT, dynamicSetsOf(parent)));
+          handles.addAll(attachMaps(self, PluginName.GERRIT, dynamicMapsOf(parent)));
           parent = parent.getParent();
         }
         if (handles.isEmpty()) {
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java b/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
deleted file mode 100644
index 994e7f2..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2012 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.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code PUT} or {@code POST} when the
- * parse method throws {@link ResourceNotFoundException}.
- */
-public interface AcceptsCreate<P extends RestResource> {
-  /**
-   * Handle creation of a child resource.
-   *
-   * @param parent parent collection handle.
-   * @param id id of the resource being created.
-   * @return a view to perform the creation. The create method must embed the id into the newly
-   *     returned view object, as it will not be passed.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> create(P parent, IdString id) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
deleted file mode 100644
index 6b5da7c..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2014 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.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code DELETE} directly on the
- * collection itself.
- */
-public interface AcceptsDelete<P extends RestResource> {
-  /**
-   * Handle deletion of a child resource by DELETE on the collection.
-   *
-   * @param parent parent collection handle.
-   * @param id id of the resource being created (optional).
-   * @return a view to perform the deletion.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> delete(P parent, IdString id) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsPost.java b/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
deleted file mode 100644
index da87d32..0000000
--- a/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2012 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.restapi;
-
-/**
- * Optional interface for {@link RestCollection}.
- *
- * <p>Collections that implement this interface can accept a {@code POST} directly on the collection
- * itself when no id was given in the path. This interface is intended to be used with
- * TopLevelResource collections. Nested collections often bind POST on the parent collection to the
- * view implementation handling the insertion of a new member.
- */
-public interface AcceptsPost<P extends RestResource> {
-  /**
-   * Handle creation of a child resource by POST on the collection.
-   *
-   * @param parent parent collection handle.
-   * @return a view to perform the creation. The id of the newly created resource should be
-   *     determined from the input body.
-   * @throws RestApiException the view cannot be constructed.
-   */
-  RestModifyView<P, ?> post(P parent) throws RestApiException;
-}
diff --git a/java/com/google/gerrit/extensions/restapi/AuthException.java b/java/com/google/gerrit/extensions/restapi/AuthException.java
index 0b4f459..fe1744b 100644
--- a/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -14,10 +14,17 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+
 /** Caller cannot perform the request operation (HTTP 403 Forbidden). */
 public class AuthException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
+  private Optional<String> advice = Optional.empty();
+
   /** @param msg message to return to the client. */
   public AuthException(String msg) {
     super(msg);
@@ -30,4 +37,19 @@
   public AuthException(String msg, Throwable cause) {
     super(msg, cause);
   }
+
+  public void setAdvice(String advice) {
+    checkArgument(!Strings.isNullOrEmpty(advice));
+    this.advice = Optional.of(advice);
+  }
+
+  /**
+   * Advice that the user can follow to acquire authorization to perform the action.
+   *
+   * <p>This may be long-form text with newlines, and may be printed to a terminal, for example in
+   * the message stream in response to a push.
+   */
+  public Optional<String> getAdvice() {
+    return advice;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiModule.java b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
index 0db2891..85bd5a1 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
@@ -28,21 +28,57 @@
   protected static final String PUT = "PUT";
   protected static final String DELETE = "DELETE";
   protected static final String POST = "POST";
+  protected static final String CREATE = "CREATE";
+  protected static final String DELETE_MISSING = "DELETE_MISSING";
+  protected static final String DELETE_ON_COLLECTION = "DELETE_ON_COLLECTION";
+  protected static final String POST_ON_COLLECTION = "POST_ON_COLLECTION";
 
   protected <R extends RestResource> ReadViewBinder<R> get(TypeLiteral<RestView<R>> viewType) {
-    return new ReadViewBinder<>(view(viewType, GET, "/"));
+    return get(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> put(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, PUT, "/"));
+    return put(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> post(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, POST, "/"));
+    return post(viewType, "/");
   }
 
   protected <R extends RestResource> ModifyViewBinder<R> delete(TypeLiteral<RestView<R>> viewType) {
-    return new ModifyViewBinder<>(view(viewType, DELETE, "/"));
+    return delete(viewType, "/");
+  }
+
+  protected <R extends RestResource> RestCollectionViewBinder<R> postOnCollection(
+      TypeLiteral<RestView<R>> viewType) {
+    return new RestCollectionViewBinder<>(
+        bind(viewType).annotatedWith(export(POST_ON_COLLECTION, "/")));
+  }
+
+  /**
+   * Creates a binder that allows to bind a REST view to handle {@code DELETE} on the REST
+   * collection of the provided view type.
+   *
+   * <p>This binding is ignored if the provided view type belongs to a root collection.
+   *
+   * @param viewType the type of the resources in the REST collection on which {@code DELETE} should
+   *     be handled
+   * @return binder that allows to bind an implementation for the REST view that should handle
+   *     {@code DELETE} on the REST collection of the provided view type
+   */
+  protected <R extends RestResource> RestCollectionViewBinder<R> deleteOnCollection(
+      TypeLiteral<RestView<R>> viewType) {
+    return new RestCollectionViewBinder<>(
+        bind(viewType).annotatedWith(export(DELETE_ON_COLLECTION, "/")));
+  }
+
+  protected <R extends RestResource> CreateViewBinder<R> create(TypeLiteral<RestView<R>> viewType) {
+    return new CreateViewBinder<>(bind(viewType).annotatedWith(export(CREATE, "/")));
+  }
+
+  protected <R extends RestResource> DeleteViewBinder<R> deleteMissing(
+      TypeLiteral<RestView<R>> viewType) {
+    return new DeleteViewBinder<>(bind(viewType).annotatedWith(export(DELETE_MISSING, "/")));
   }
 
   protected <R extends RestResource> ReadViewBinder<R> get(
@@ -70,7 +106,7 @@
     return new ChildCollectionBinder<>(view(type, GET, name));
   }
 
-  protected <R extends RestResource> LinkedBindingBuilder<RestView<R>> view(
+  private <R extends RestResource> LinkedBindingBuilder<RestView<R>> view(
       TypeLiteral<RestView<R>> viewType, String method, String name) {
     return bind(viewType).annotatedWith(export(method, name));
   }
@@ -137,6 +173,90 @@
     }
   }
 
+  public static class RestCollectionViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private RestCollectionViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>> void toInstance(
+        T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
+  public static class CreateViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private CreateViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>> void toInstance(
+        T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
+  public static class DeleteViewBinder<C extends RestResource> {
+    private final LinkedBindingBuilder<RestView<C>> binder;
+
+    private DeleteViewBinder(LinkedBindingBuilder<RestView<C>> binder) {
+      this.binder = binder;
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder to(Class<T> impl) {
+      return binder.to(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        void toInstance(T impl) {
+      binder.toInstance(impl);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+      return binder.toProvider(providerType);
+    }
+
+    public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+      return binder.toProvider(provider);
+    }
+  }
+
   public static class ChildCollectionBinder<P extends RestResource> {
     private final LinkedBindingBuilder<RestView<P>> binder;
 
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
new file mode 100644
index 0000000..25cdb76
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionCreateView.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 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.restapi;
+
+/**
+ * RestView that supports accepting input and creating a resource.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. Create views can be
+ * invoked by the HTTP methods {@code PUT} and {@code POST}.
+ *
+ * <p>The RestCreateView is only invoked when the parse method of the {@code RestCollection} throws
+ * {@link ResourceNotFoundException}, and hence the resource doesn't exist yet.
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource that is created
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionCreateView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  /**
+   * Process the view operation by creating the resource.
+   *
+   * @param parentResource parent resource of the resource that should be created
+   * @param input input after parsing from request.
+   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
+   *     to JSON.
+   * @throws RestApiException if the resource creation is rejected
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Object apply(P parentResource, IdString id, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
new file mode 100644
index 0000000..7e5649c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionDeleteMissingView.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 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.restapi;
+
+/**
+ * RestView that supports accepting input and deleting a resource that is missing.
+ *
+ * <p>The RestDeleteMissingView solely exists to support a special case for creating a change edit
+ * by deleting a path in the non-existing change edit. This interface should not be used for new
+ * REST API's.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. Delete views can be
+ * invoked by the HTTP method {@code DELETE}.
+ *
+ * <p>The RestDeleteMissingView is only invoked when the parse method of the {@code RestCollection}
+ * throws {@link ResourceNotFoundException}, and hence the resource doesn't exist yet.
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource that id deleted
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionDeleteMissingView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  /**
+   * Process the view operation by deleting the resource.
+   *
+   * @param parentResource parent resource of the resource that should be deleted
+   * @param input input after parsing from request.
+   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
+   *     to JSON.
+   * @throws RestApiException if the resource creation is rejected
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
+   */
+  Object apply(P parentResource, IdString id, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
new file mode 100644
index 0000000..acabf96
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionModifyView.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2018 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.restapi;
+
+/**
+ * RestView on a RestCollection that supports accepting input.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. RestCollectionModifyViews
+ * can be invoked by the HTTP methods {@code POST} and {@code DELETE} ({@code DELETE} is only
+ * supported on child collections).
+ *
+ * @param <P> type of the parent resource
+ * @param <C> type of the child resource
+ * @param <I> type of input the JSON parser will parse the input into.
+ */
+public interface RestCollectionModifyView<P extends RestResource, C extends RestResource, I>
+    extends RestCollectionView<P, C, I> {
+
+  Object apply(P parentResource, I input) throws Exception;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/RestCollectionView.java b/java/com/google/gerrit/extensions/restapi/RestCollectionView.java
new file mode 100644
index 0000000..c29a58f
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/RestCollectionView.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2018 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.restapi;
+
+/**
+ * Any type of view on {@link RestCollection}, see {@link RestCollectionModifyView} for updates and
+ * deletes and {@link RestCollectionCreateView} for member creation.
+ */
+public interface RestCollectionView<P extends RestResource, C extends RestResource, I>
+    extends RestView<C> {}
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index c5163d1..929e182 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -54,6 +54,13 @@
     Truth.assertThat(trimMessages()).isEqualTo(Arrays.stream(expectedLines).collect(joining("\n")));
   }
 
+  public void containsMessages(String... expectedLines) {
+    checkArgument(expectedLines.length > 0, "use hasNoMessages()");
+    isNotNull();
+    Iterable<String> got = Splitter.on("\n").split(trimMessages());
+    Truth.assertThat(got).containsAllIn(expectedLines).inOrder();
+  }
+
   private String trimMessages() {
     return trimMessages(actual().getMessages());
   }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index a2d901f..3f090a1 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -59,8 +59,6 @@
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final String MIME_TYPE = "application/pgp-keys";
-
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index b8b0bc8..9d171d5 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -76,7 +76,7 @@
         // synchronized.
         if (!initializedFilters.contains(filter)) {
           filter.init(filterConfig);
-          initializedFilters.add(filter);
+          initializedFilters.add("gerrit", filter);
         }
       } else {
         ret = false;
@@ -89,7 +89,7 @@
       initializedFilters = new DynamicSet<>();
       for (AllRequestFilter filter : filtersToCleanUp) {
         if (filters.contains(filter)) {
-          initializedFilters.add(filter);
+          initializedFilters.add("gerrit", filter);
         } else {
           filter.destroy();
         }
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 4bd3e2e..fae7c6a 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -13,13 +13,13 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/http",
-        "//java/com/google/gwtexpui/linker:server",
-        "//java/com/google/gwtexpui/server",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
         "//lib:gson",
diff --git a/java/com/google/gerrit/httpd/GerritAuthModule.java b/java/com/google/gerrit/httpd/GerritAuthModule.java
new file mode 100644
index 0000000..c0ef207
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GerritAuthModule.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 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;
+
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.servlet.ServletModule;
+import javax.servlet.Filter;
+
+/** Configures filter for authenticating REST requests. */
+public class GerritAuthModule extends ServletModule {
+  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GerritAuthModule(AuthConfig authConfig) {
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  protected void configureServlets() {
+    Class<? extends Filter> authFilter = retreiveAuthFilterFromConfig(authConfig);
+
+    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
+    filter("/a/*").through(authFilter);
+  }
+
+  static Class<? extends Filter> retreiveAuthFilterFromConfig(AuthConfig authConfig) {
+    Class<? extends Filter> authFilter;
+    if (authConfig.isTrustContainerAuth()) {
+      authFilter = ContainerAuthFilter.class;
+    } else {
+      authFilter =
+          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
+              ? ProjectOAuthFilter.class
+              : ProjectBasicAuthFilter.class;
+    }
+    return authFilter;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/GitOverHttpModule.java b/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 3f3737d..8400d60 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,20 +14,16 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+import static com.google.gerrit.httpd.GitOverHttpServlet.URL_REGEX;
 
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.servlet.ServletModule;
-import javax.servlet.Filter;
 
 /** Configures Git access over HTTP with authentication. */
 public class GitOverHttpModule extends ServletModule {
-  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
-
   private final AuthConfig authConfig;
   private final DownloadConfig downloadConfig;
 
@@ -39,28 +35,10 @@
 
   @Override
   protected void configureServlets() {
-    Class<? extends Filter> authFilter;
-    if (authConfig.isTrustContainerAuth()) {
-      authFilter = ContainerAuthFilter.class;
-    } else {
-      authFilter =
-          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
-              ? ProjectOAuthFilter.class
-              : ProjectBasicAuthFilter.class;
+    if (downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
+        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP)) {
+      filterRegex(URL_REGEX).through(GerritAuthModule.retreiveAuthFilterFromConfig(authConfig));
+      serveRegex(URL_REGEX).with(GitOverHttpServlet.class);
     }
-
-    if (isHttpEnabled()) {
-      String git = GitOverHttpServlet.URL_REGEX;
-      filterRegex(git).through(authFilter);
-      serveRegex(git).with(GitOverHttpServlet.class);
-    }
-
-    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
-    filter("/a/*").through(authFilter);
-  }
-
-  private boolean isHttpEnabled() {
-    return downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
-        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP);
   }
 }
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index c1a75bb..ec941a5 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -345,17 +345,19 @@
       boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
 
       AsyncReceiveCommits arc = (AsyncReceiveCommits) request.getAttribute(ATT_ARC);
+
+      // Send refs down the wire.
       ReceivePack rp = arc.getReceivePack();
       rp.getAdvertiseRefsHook().advertiseRefs(rp);
-      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
 
-      Capable s;
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
+      Capable canUpload;
       try {
         permissionBackend
             .currentUser()
             .project(state.getNameKey())
             .check(ProjectPermission.RUN_RECEIVE_PACK);
-        s = arc.canUpload();
+        canUpload = arc.canUpload();
       } catch (AuthException e) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
@@ -367,12 +369,12 @@
         throw new RuntimeException(e);
       }
 
-      if (s != Capable.OK) {
+      if (canUpload != Capable.OK) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
             (HttpServletResponse) response,
             HttpServletResponse.SC_FORBIDDEN,
-            "\n" + s.getMessage());
+            "\n" + canUpload.getMessage());
         return;
       }
 
diff --git a/java/com/google/gwtexpui/server/CacheControlFilter.java b/java/com/google/gerrit/httpd/GwtCacheControlFilter.java
similarity index 94%
rename from java/com/google/gwtexpui/server/CacheControlFilter.java
rename to java/com/google/gerrit/httpd/GwtCacheControlFilter.java
index 571f72d..5ac3d2f 100644
--- a/java/com/google/gwtexpui/server/CacheControlFilter.java
+++ b/java/com/google/gerrit/httpd/GwtCacheControlFilter.java
@@ -12,8 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gwtexpui.server;
+package com.google.gerrit.httpd;
 
+import com.google.gerrit.util.http.CacheHeaders;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 import javax.servlet.Filter;
@@ -46,7 +48,8 @@
  *   &lt;/filter-mapping&gt;
  * </pre>
  */
-public class CacheControlFilter implements Filter {
+@Singleton
+class GwtCacheControlFilter implements Filter {
   @Override
   public void init(FilterConfig config) {}
 
diff --git a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
index 8b82c00..c41a7b9 100644
--- a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
+++ b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
@@ -59,7 +59,7 @@
       HttpServletResponse rsp = (HttpServletResponse) response;
       try {
         List<DocResult> result = searcher.doQuery(request.getParameter("q"));
-        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
+        RestApiServlet.replyJson(req, rsp, false, ImmutableListMultimap.of(), result);
       } catch (DocQueryException e) {
         logger.atSevere().withCause(e).log("Doc search failed");
         rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 6210385..f337718 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritOptions;
-import com.google.gwtexpui.server.CacheControlFilter;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.internal.UniqueAnnotations;
@@ -56,8 +55,7 @@
 
   @Override
   protected void configureServlets() {
-    filter("/*").through(Key.get(CacheControlFilter.class));
-    bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
+    filter("/*").through(GwtCacheControlFilter.class);
 
     if (options.enableGwtUi()) {
       filter("/").through(XsrfCookieFilter.class);
@@ -265,8 +263,6 @@
       throws IOException {
     final StringBuilder url = new StringBuilder();
     url.append(req.getContextPath());
-    url.append('/');
-    url.append('#');
     url.append(target);
     rsp.sendRedirect(url.toString());
   }
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index 538d605..d115f43 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -56,7 +56,9 @@
     installAuthModule();
     if (options.enableMasterFeatures()) {
       install(new UrlModule(options, authConfig));
-      install(new UiRpcModule());
+      if (options.enableGwtUi()) {
+        install(new UiRpcModule());
+      }
     }
     install(new GerritRequestModule());
     install(new GitOverHttpServlet.Module(options.enableMasterFeatures()));
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 77c79bb..ea01809 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index c7229bc..0b3c29d 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 6340b22..fd2f628 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
index e93b0b6..f21c96e 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index 6370476..116ad6d 100644
--- a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.AuthenticationFailedException;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 96726ad..7315ce1 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -10,6 +10,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//lib:gson",
         "//lib:guava",
         "//lib:gwtorm",
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index 9c48832..f80e9d5 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -11,8 +11,9 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gwtexpui/server",
+        "//java/com/google/gerrit/util/http",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
index a97e8ae..23cf468 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.openid;
 
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java b/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
index af853cc..d57e629 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
@@ -18,7 +18,7 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.server.config.GitwebCgiConfig;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
index 5e22081..feee3ba 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
index 651b582..82dd901 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
@@ -18,7 +18,7 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.server.config.GitwebCgiConfig;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 383efd3..917ea91 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -55,7 +55,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -80,6 +80,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -668,17 +669,17 @@
   private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
-              StringBuilder b = new StringBuilder();
               try (BufferedReader br =
                   new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
-                String line;
-                while ((line = br.readLine()) != null) {
-                  if (b.length() > 0) {
-                    b.append('\n');
-                  }
-                  b.append("CGI: ").append(line);
+                String err =
+                    br.lines()
+                        .filter(s -> !s.isEmpty())
+                        .map(s -> "CGI: " + s)
+                        .collect(Collectors.joining("\n"))
+                        .trim();
+                if (!err.isEmpty()) {
+                  logger.atSevere().log(err);
                 }
-                logger.atSevere().log(b.toString());
               } catch (IOException e) {
                 logger.atSevere().withCause(e).log("Unexpected error copying stderr from CGI");
               }
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 292ceff..d557c0e 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -18,6 +18,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/git/receive",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 9ce7690..cb476af 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GerritAuthModule;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -47,6 +48,7 @@
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.api.GerritApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -342,6 +344,7 @@
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
     modules.add(new LocalMergeSuperSetComputation.Module());
+    modules.add(new AuditModule());
 
     // Plugin module needs to be inserted *before* the index module.
     // There is the concept of LifecycleModule, in Gerrit's own extension
@@ -420,9 +423,10 @@
   private Injector createWebInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     if (sshInjector != null) {
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 63ce211..74cadd3 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -52,8 +52,8 @@
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
-import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -456,8 +456,8 @@
       }
     }
 
-    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
-    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
+    cmds.sort(PluginEntry.COMPARATOR_BY_NAME);
+    docs.sort(PluginEntry.COMPARATOR_BY_NAME);
 
     StringBuilder md = new StringBuilder();
     md.append(String.format("# Plugin %s #\n", pluginName));
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index 0ee22fa..67ee3ba 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.GuiceFilter;
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
index 92a5aaa..2b390a9 100644
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -23,14 +23,15 @@
 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 com.google.gerrit.launcher.GerritLauncher;
+import com.google.gerrit.util.http.CacheHeaders;
 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.ArrayList;
+import java.util.List;
 import java.util.Properties;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -122,20 +123,19 @@
     }
   }
 
-  private 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;
-  }
-
   private ProcessBuilder newBuildProcess(Label label) throws IOException {
-    Properties properties = loadBuildProperties(sourceRoot.resolve(".bazel_path"));
+    Properties properties = GerritLauncher.loadBuildProperties(sourceRoot.resolve(".bazel_path"));
     String bazel = firstNonNull(properties.getProperty("bazel"), "bazel");
-    ProcessBuilder proc = new ProcessBuilder(bazel, "build", label.fullName());
+    List<String> cmd = new ArrayList<>();
+    cmd.add(bazel);
+    cmd.add("build");
+    if (GerritLauncher.isJdk9OrLater()) {
+      String v = GerritLauncher.getJdkVersionPostJdk8();
+      cmd.add("--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
+      cmd.add("--java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
+    }
+    cmd.add(label.fullName());
+    ProcessBuilder proc = new ProcessBuilder(cmd);
     if (properties.containsKey("PATH")) {
       proc.environment().put("PATH", properties.getProperty("PATH"));
     }
diff --git a/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index 74868d7..160732e 100644
--- a/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.GetDiffPreferences;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.JsonServlet;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 90b25d9..a414e84 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -37,7 +37,8 @@
   private static final long serialVersionUID = 1L;
   protected final byte[] indexSource;
 
-  IndexServlet(String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
+  IndexServlet(
+      @Nullable String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
       throws URISyntaxException {
     String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
     SoyFileSet.Builder builder = SoyFileSet.builder();
@@ -62,7 +63,7 @@
     }
   }
 
-  static String computeCanonicalPath(String canonicalURL) throws URISyntaxException {
+  static String computeCanonicalPath(@Nullable String canonicalURL) throws URISyntaxException {
     if (Strings.isNullOrEmpty(canonicalURL)) {
       return "";
     }
diff --git a/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java b/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
index 10735a5..e12f0a5 100644
--- a/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
+++ b/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
index 90aedbe..c6c3367 100644
--- a/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ b/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.raw;
 
-import com.google.gwtexpui.linker.server.UserAgentRule;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 035653d..3244232 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -34,7 +34,8 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.server.UsedAt;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -324,6 +325,7 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index 55bc2a6..1d1fe6cc 100644
--- a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -17,7 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.jcraft.jsch.HostKey;
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 06ec799..20b799b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
@@ -134,9 +133,9 @@
     if (!options.headless()) {
       install(new CoreStaticModule());
     }
-    if (options.enablePolyGerrit()) {
-      install(new PolyGerritModule());
-    }
+
+    install(new PolyGerritModule());
+
     if (options.enableGwtUi()) {
       install(new GwtUiModule());
     }
@@ -430,8 +429,6 @@
       this.polygerritUI = polygerritUI;
       this.bowerComponentServlet = bowerComponentServlet;
       this.fontServlet = fontServlet;
-      checkState(
-          options.enablePolyGerrit(), "can't install PolyGerritFilter when PolyGerrit is disabled");
     }
 
     @Override
@@ -459,7 +456,7 @@
       // Special case assets during development that are built by Bazel and not
       // served out of the source tree.
       //
-      // In the war case, these are either inlined by vulcanize, or live under
+      // In the war case, these are either inlined, or live under
       // /polygerrit_ui in the war file, so we can just treat them as normal
       // assets.
       if (paths.isDev()) {
@@ -520,7 +517,7 @@
     }
 
     private boolean isPolyGerritCookie(HttpServletRequest req) {
-      UiType type = options.defaultUi();
+      UiType type = UiType.POLYGERRIT;
       Cookie[] all = req.getCookies();
       if (all != null) {
         for (Cookie c : all) {
@@ -537,10 +534,10 @@
     }
 
     private void setPolyGerritCookie(HttpServletRequest req, HttpServletResponse res, UiType pref) {
-      // Only actually set a cookie if both UIs are enabled in the server;
+      // Only actually set a cookie if GWT UI is enabled in addition to default PG UI;
       // otherwise clear it.
       Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
-      if (options.enablePolyGerrit() && options.enableGwtUi()) {
+      if (options.enableGwtUi()) {
         cookie.setPath("/");
         cookie.setSecure(isSecure(req));
         cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
diff --git a/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/java/com/google/gerrit/httpd/raw/UserAgentRule.java
similarity index 93%
rename from java/com/google/gwtexpui/linker/server/UserAgentRule.java
rename to java/com/google/gerrit/httpd/raw/UserAgentRule.java
index 8f7bede..4aac243 100644
--- a/java/com/google/gwtexpui/linker/server/UserAgentRule.java
+++ b/java/com/google/gerrit/httpd/raw/UserAgentRule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gwtexpui.linker.server;
+package com.google.gerrit.httpd.raw;
 
 import static java.util.regex.Pattern.compile;
 
@@ -28,15 +28,15 @@
  *
  * <p>Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
  */
-public class UserAgentRule {
+class UserAgentRule {
   private static final Pattern msie = compile(".*msie ([0-11]+)\\.([0-11]+).*");
   private static final Pattern gecko = compile(".*rv:([0-9]+)\\.([0-9]+).*");
 
-  public String getName() {
+  String getName() {
     return "user.agent";
   }
 
-  public String select(HttpServletRequest req) {
+  String select(HttpServletRequest req) {
     String ua = req.getHeader("User-Agent");
     if (ua == null) {
       return null;
diff --git a/java/com/google/gerrit/httpd/resources/Resource.java b/java/com/google/gerrit/httpd/resources/Resource.java
index bfa0b95..878912e 100644
--- a/java/com/google/gerrit/httpd/resources/Resource.java
+++ b/java/com/google/gerrit/httpd/resources/Resource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.resources;
 
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import java.io.IOException;
 import java.io.Serializable;
 import javax.servlet.http.HttpServletRequest;
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index bfaf0c7..172321d 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -38,11 +38,11 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
-import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.io.IOException;
@@ -56,8 +56,10 @@
 import org.kohsuke.args4j.CmdLineException;
 
 public class ParameterParser {
+  public static final String TRACE_PARAMETER = "trace";
+
   private static final ImmutableSet<String> RESERVED_KEYS =
-      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
+      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields", TRACE_PARAMETER);
 
   @AutoValue
   public abstract static class QueryParams {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
index 4af03a3..562687b 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.restapi;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.httpd.restapi.RestApiServlet.ViewData;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Counter2;
@@ -79,7 +80,8 @@
         break;
       }
     }
-    if (!Strings.isNullOrEmpty(viewData.pluginName) && !"gerrit".equals(viewData.pluginName)) {
+    if (!Strings.isNullOrEmpty(viewData.pluginName)
+        && !PluginName.GERRIT.equals(viewData.pluginName)) {
       impl = viewData.pluginName + '-' + impl;
     }
     return impl;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 8b770f9..e0559f1 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
@@ -48,6 +49,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -68,9 +70,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -88,6 +88,10 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.RestCollectionDeleteMissingView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestResource;
@@ -106,10 +110,13 @@
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -123,13 +130,13 @@
 import com.google.gson.stream.JsonToken;
 import com.google.gson.stream.JsonWriter;
 import com.google.gson.stream.MalformedJsonException;
-import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
@@ -176,6 +183,8 @@
 
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
+  @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+
   // HTTP 422 Unprocessable Entity.
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
@@ -279,251 +288,347 @@
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      if (isCorsPreflight(req)) {
-        doCorsPreflight(req, res);
-        return;
-      }
+    try (TraceContext traceContext = enableTracing(req, res)) {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
+        logger.atFinest().log(
+            "Received REST request: %s %s (parameters: %s)",
+            req.getMethod(), req.getRequestURI(), getParameterNames(req));
+        logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
 
-      qp = ParameterParser.getQueryParams(req);
-      checkCors(req, res, qp.hasXdOverride());
-      if (qp.hasXdOverride()) {
-        req = applyXdOverrides(req, qp);
-      }
-      checkUserSession(req);
-
-      List<IdString> path = splitPath(req);
-      RestCollection<RestResource, RestResource> rc = members.get();
-      globals
-          .permissionBackend
-          .currentUser()
-          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-      viewData = new ViewData(null, null);
-
-      if (path.isEmpty()) {
-        if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(qp.params());
+        if (isCorsPreflight(req)) {
+          doCorsPreflight(req, res);
+          return;
         }
 
-        if (isRead(req)) {
-          viewData = new ViewData(null, rc.list());
-        } else if (rc instanceof AcceptsPost && isPost(req)) {
-          @SuppressWarnings("unchecked")
-          AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
-          viewData = new ViewData(null, ac.post(rsrc));
-        } else {
-          throw new MethodNotAllowedException();
+        qp = ParameterParser.getQueryParams(req);
+        checkCors(req, res, qp.hasXdOverride());
+        if (qp.hasXdOverride()) {
+          req = applyXdOverrides(req, qp);
         }
-      } else {
-        IdString id = path.remove(0);
-        try {
-          rsrc = rc.parse(rsrc, id);
-          if (path.isEmpty()) {
-            checkPreconditions(req);
-          }
-        } catch (ResourceNotFoundException e) {
-          if (rc instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
-            viewData = new ViewData(null, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rsrc, rc, req.getMethod(), path);
-        }
-      }
-      checkRequiresCapability(viewData);
+        checkUserSession(req);
 
-      while (viewData.view instanceof RestCollection<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollection<RestResource, RestResource> c =
-            (RestCollection<RestResource, RestResource>) viewData.view;
+        List<IdString> path = splitPath(req);
+        RestCollection<RestResource, RestResource> rc = members.get();
+        globals
+            .permissionBackend
+            .currentUser()
+            .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
+
+        viewData = new ViewData(null, null);
 
         if (path.isEmpty()) {
+          if (rc instanceof NeedsParams) {
+            ((NeedsParams) rc).setParams(qp.params());
+          }
+
           if (isRead(req)) {
-            viewData = new ViewData(null, c.list());
-          } else if (c instanceof AcceptsPost && isPost(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
-            viewData = new ViewData(null, ac.post(rsrc));
-          } else if (c instanceof AcceptsDelete && isDelete(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(null, ac.delete(rsrc, null));
+            viewData = new ViewData(null, rc.list());
+          } else if (isPost(req)) {
+            RestView<RestResource> restCollectionView =
+                rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+            if (restCollectionView != null) {
+              viewData = new ViewData(null, restCollectionView);
+            } else {
+              throw methodNotAllowed(req);
+            }
           } else {
-            throw new MethodNotAllowedException();
+            // DELETE on root collections is not supported
+            throw methodNotAllowed(req);
           }
-          break;
-        }
-        IdString id = path.remove(0);
-        try {
-          rsrc = c.parse(rsrc, id);
-          checkPreconditions(req);
-          viewData = new ViewData(null, null);
-        } catch (ResourceNotFoundException e) {
-          if (c instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else if (c instanceof AcceptsDelete && path.isEmpty() && isDelete(req)) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
-            status = SC_NO_CONTENT;
-          } else {
-            throw e;
+        } else {
+          IdString id = path.remove(0);
+          try {
+            rsrc = rc.parse(rsrc, id);
+            if (path.isEmpty()) {
+              checkPreconditions(req);
+            }
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
+
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView =
+                  rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else {
+              throw e;
+            }
           }
-        }
-        if (viewData.view == null) {
-          viewData = view(rsrc, c, req.getMethod(), path);
+          if (viewData.view == null) {
+            viewData = view(rc, req.getMethod(), path);
+          }
         }
         checkRequiresCapability(viewData);
-      }
 
-      if (notModified(req, rsrc, viewData.view)) {
-        res.sendError(SC_NOT_MODIFIED);
-        return;
-      }
+        while (viewData.view instanceof RestCollection<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollection<RestResource, RestResource> c =
+              (RestCollection<RestResource, RestResource>) viewData.view;
 
-      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-        return;
-      }
+          if (path.isEmpty()) {
+            if (isRead(req)) {
+              viewData = new ViewData(null, c.list());
+            } else if (isPost(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else {
+              throw methodNotAllowed(req);
+            }
+            break;
+          }
+          IdString id = path.remove(0);
+          try {
+            rsrc = c.parse(rsrc, id);
+            checkPreconditions(req);
+            viewData = new ViewData(null, null);
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
 
-      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-      } else if (viewData.view instanceof RestModifyView<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestModifyView<RestResource, Object> m =
-            (RestModifyView<RestResource, Object>) viewData.view;
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView =
+                  c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else {
+              throw e;
+            }
+          }
+          if (viewData.view == null) {
+            viewData = view(c, req.getMethod(), path);
+          }
+          checkRequiresCapability(viewData);
+        }
 
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+        if (notModified(req, rsrc, viewData.view)) {
+          res.sendError(SC_NOT_MODIFIED);
+          return;
+        }
+
+        if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+          return;
+        }
+
+        if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+          result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+        } else if (viewData.view instanceof RestModifyView<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestModifyView<RestResource, Object> m =
+              (RestModifyView<RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionCreateView<RestResource, RestResource, Object> m =
+              (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+              (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionModifyView<RestResource, RestResource, Object> m =
+              (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else {
+          throw new ResourceNotFoundException();
+        }
+
+        if (result instanceof Response) {
+          @SuppressWarnings("rawtypes")
+          Response<?> r = (Response) result;
+          status = r.statusCode();
+          configureCaching(req, res, rsrc, viewData.view, r.caching());
+        } else if (result instanceof Response.Redirect) {
+          CacheHeaders.setNotCacheable(res);
+          String location = ((Response.Redirect) result).location();
+          res.sendRedirect(location);
+          logger.atFinest().log("REST call redirected to: %s", location);
+          return;
+        } else if (result instanceof Response.Accepted) {
+          CacheHeaders.setNotCacheable(res);
+          res.setStatus(SC_ACCEPTED);
+          res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+          logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
+          return;
+        } else {
+          CacheHeaders.setNotCacheable(res);
+        }
+        res.setStatus(status);
+        logger.atFinest().log("REST call succeeded: %d", status);
+
+        if (result != Response.none()) {
+          result = Response.unwrap(result);
+          if (result instanceof BinaryResult) {
+            responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+          } else {
+            responseBytes = replyJson(req, res, false, qp.config(), result);
           }
         }
-      } else {
-        throw new ResourceNotFoundException();
-      }
-
-      if (result instanceof Response) {
-        @SuppressWarnings("rawtypes")
-        Response<?> r = (Response) result;
-        status = r.statusCode();
-        configureCaching(req, res, rsrc, viewData.view, r.caching());
-      } else if (result instanceof Response.Redirect) {
-        CacheHeaders.setNotCacheable(res);
-        res.sendRedirect(((Response.Redirect) result).location());
-        return;
-      } else if (result instanceof Response.Accepted) {
-        CacheHeaders.setNotCacheable(res);
-        res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-        return;
-      } else {
-        CacheHeaders.setNotCacheable(res);
-      }
-      res.setStatus(status);
-
-      if (result != Response.none()) {
-        result = Response.unwrap(result);
-        if (result instanceof BinaryResult) {
-          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
-        } else {
-          responseBytes = replyJson(req, res, qp.config(), result);
-        }
-      }
-    } catch (MalformedJsonException | JsonParseException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (BadRequestException e) {
-      responseBytes =
-          replyError(
-              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
-    } catch (AuthException e) {
-      responseBytes =
-          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
-    } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-    } catch (ResourceNotFoundException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
-    } catch (MethodNotAllowedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_METHOD_NOT_ALLOWED,
-              messageOr(e, "Method Not Allowed"),
-              e.caching(),
-              e);
-    } catch (ResourceConflictException e) {
-      responseBytes =
-          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
-    } catch (PreconditionFailedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_PRECONDITION_FAILED,
-              messageOr(e, "Precondition Failed"),
-              e.caching(),
-              e);
-    } catch (UnprocessableEntityException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_UNPROCESSABLE_ENTITY,
-              messageOr(e, "Unprocessable Entity"),
-              e.caching(),
-              e);
-    } catch (NotImplementedException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-    } catch (UpdateException e) {
-      Throwable t = e.getCause();
-      if (t instanceof LockFailureException) {
+      } catch (MalformedJsonException | JsonParseException e) {
         responseBytes =
-            replyError(req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
-      } else {
+            replyError(
+                req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+      } catch (BadRequestException e) {
+        responseBytes =
+            replyError(
+                req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+      } catch (AuthException e) {
+        responseBytes =
+            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+      } catch (AmbiguousViewException e) {
+        responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      } catch (ResourceNotFoundException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+      } catch (MethodNotAllowedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_METHOD_NOT_ALLOWED,
+                messageOr(e, "Method Not Allowed"),
+                e.caching(),
+                e);
+      } catch (ResourceConflictException e) {
+        responseBytes =
+            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+      } catch (PreconditionFailedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_PRECONDITION_FAILED,
+                messageOr(e, "Precondition Failed"),
+                e.caching(),
+                e);
+      } catch (UnprocessableEntityException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_UNPROCESSABLE_ENTITY,
+                messageOr(e, "Unprocessable Entity"),
+                e.caching(),
+                e);
+      } catch (NotImplementedException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
+      } catch (UpdateException e) {
+        Throwable t = e.getCause();
+        if (t instanceof LockFailureException) {
+          responseBytes =
+              replyError(
+                  req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
+        } else {
+          status = SC_INTERNAL_SERVER_ERROR;
+          responseBytes = handleException(e, req, res);
+        }
+      } catch (Exception e) {
         status = SC_INTERNAL_SERVER_ERROR;
         responseBytes = handleException(e, req, res);
+      } finally {
+        String metric =
+            viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+        globals.metrics.count.increment(metric);
+        if (status >= SC_BAD_REQUEST) {
+          globals.metrics.errorCount.increment(metric, status);
+        }
+        if (responseBytes != -1) {
+          globals.metrics.responseBytes.record(metric, responseBytes);
+        }
+        globals.metrics.serverLatency.record(
+            metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+        globals.auditService.dispatch(
+            new ExtendedHttpAuditEvent(
+                globals.webSession.get().getSessionId(),
+                globals.currentUser.get(),
+                req,
+                auditStartTs,
+                qp != null ? qp.params() : ImmutableListMultimap.of(),
+                inputRequestBody,
+                status,
+                result,
+                rsrc,
+                viewData == null ? null : viewData.view));
       }
-    } catch (Exception e) {
-      status = SC_INTERNAL_SERVER_ERROR;
-      responseBytes = handleException(e, req, res);
-    } finally {
-      String metric =
-          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
-      globals.metrics.count.increment(metric);
-      if (status >= SC_BAD_REQUEST) {
-        globals.metrics.errorCount.increment(metric, status);
-      }
-      if (responseBytes != -1) {
-        globals.metrics.responseBytes.record(metric, responseBytes);
-      }
-      globals.metrics.serverLatency.record(
-          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(
-          new ExtendedHttpAuditEvent(
-              globals.webSession.get().getSessionId(),
-              globals.currentUser.get(),
-              req,
-              auditStartTs,
-              qp != null ? qp.params() : ImmutableListMultimap.of(),
-              inputRequestBody,
-              status,
-              result,
-              rsrc,
-              viewData == null ? null : viewData.view));
     }
   }
 
@@ -735,6 +840,24 @@
     return ((ParameterizedType) supertype).getActualTypeArguments()[1];
   }
 
+  private static Type inputType(RestCollectionView<RestResource, RestResource, Object> m) {
+    // MyCollectionView implements RestCollectionView<SomeResource, SomeResource, MyInput>
+    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
+
+    // RestCollectionView<SomeResource, SomeResource, MyInput>
+    // This is smart enough to resolve even when there are intervening subclasses, even if they have
+    // reordered type arguments.
+    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestCollectionView.class);
+
+    Type supertype = supertypeLiteral.getType();
+    checkState(
+        supertype instanceof ParameterizedType,
+        "supertype of %s is not parameterized: %s",
+        typeLiteral,
+        supertypeLiteral);
+    return ((ParameterizedType) supertype).getActualTypeArguments()[2];
+  }
+
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -868,9 +991,22 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
+  /**
+   * Sets a JSON reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param config config parameters for the JSON formatting
+   * @param result the object that should be formatted as JSON
+   * @return the length of the response
+   * @throws IOException
+   */
   public static long replyJson(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
+      boolean allowTracing,
       ListMultimap<String, String> config,
       Object result)
       throws IOException {
@@ -885,6 +1021,21 @@
     }
     w.write('\n');
     w.flush();
+
+    if (allowTracing) {
+      logger.atFinest().log(
+          "JSON response body:\n%s",
+          lazy(
+              () -> {
+                try {
+                  ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
+                  buf.writeTo(debugOut, null);
+                  return debugOut.toString(UTF_8.name());
+                } catch (IOException e) {
+                  return "<JSON formatting failed>";
+                }
+              }));
+    }
     return replyBinaryResult(
         req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
@@ -1066,10 +1217,7 @@
   }
 
   private ViewData view(
-      RestResource rsrc,
-      RestCollection<RestResource, RestResource> rc,
-      String method,
-      List<IdString> path)
+      RestCollection<RestResource, RestResource> rc, String method, List<IdString> path)
       throws AmbiguousViewException, RestApiException {
     DynamicMap<RestView<RestResource>> views = rc.views();
     final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
@@ -1093,24 +1241,21 @@
         return new ViewData(p.get(0), view);
       }
       view = views.get(p.get(0), "GET." + viewname);
-      if (view != null && view instanceof AcceptsPost && "POST".equals(method)) {
-        @SuppressWarnings("unchecked")
-        AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
-        return new ViewData(p.get(0), ap.post(rsrc));
+      if (view != null) {
+        return new ViewData(p.get(0), view);
       }
       throw new ResourceNotFoundException(projection);
     }
 
     String name = method + "." + p.get(0);
-    RestView<RestResource> core = views.get("gerrit", name);
+    RestView<RestResource> core = views.get(PluginName.GERRIT, name);
     if (core != null) {
-      return new ViewData(null, core);
+      return new ViewData(PluginName.GERRIT, core);
     }
-    core = views.get("gerrit", "GET." + p.get(0));
-    if (core instanceof AcceptsPost && "POST".equals(method)) {
-      @SuppressWarnings("unchecked")
-      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-      return new ViewData(null, ap.post(rsrc));
+
+    core = views.get(PluginName.GERRIT, "GET." + p.get(0));
+    if (core != null) {
+      return new ViewData(PluginName.GERRIT, core);
     }
 
     Map<String, RestView<RestResource>> r = new TreeMap<>();
@@ -1171,6 +1316,51 @@
     }
   }
 
+  private List<String> getParameterNames(HttpServletRequest req) {
+    List<String> parameterNames = new ArrayList<>(req.getParameterMap().keySet());
+    Collections.sort(parameterNames);
+    return parameterNames;
+  }
+
+  private TraceContext enableTracing(HttpServletRequest req, HttpServletResponse res) {
+    // There are 2 ways to enable tracing for REST calls:
+    // 1. by using the 'trace' or 'trace=<trace-id>' request parameter
+    // 2. by setting the 'X-Gerrit-Trace:' or 'X-Gerrit-Trace:<trace-id>' header
+    String traceValueFromHeader = req.getHeader(X_GERRIT_TRACE);
+    String traceValueFromRequestParam = req.getParameter(ParameterParser.TRACE_PARAMETER);
+    boolean doTrace = traceValueFromHeader != null || traceValueFromRequestParam != null;
+
+    // Check whether no trace ID, one trace ID or 2 different trace IDs have been specified.
+    String traceId1;
+    String traceId2;
+    if (!Strings.isNullOrEmpty(traceValueFromHeader)) {
+      traceId1 = traceValueFromHeader;
+      if (!Strings.isNullOrEmpty(traceValueFromRequestParam)
+          && !traceValueFromHeader.equals(traceValueFromRequestParam)) {
+        traceId2 = traceValueFromRequestParam;
+      } else {
+        traceId2 = null;
+      }
+    } else {
+      traceId1 = Strings.emptyToNull(traceValueFromRequestParam);
+      traceId2 = null;
+    }
+
+    // Use the first trace ID to start tracing. If this trace ID is null, a trace ID will be
+    // generated.
+    TraceContext traceContext =
+        TraceContext.newTrace(
+            doTrace,
+            traceId1,
+            (tagName, traceId) -> res.setHeader(X_GERRIT_TRACE, traceId.toString()));
+    // If a second trace ID was specified, add a tag for it as well.
+    if (traceId2 != null) {
+      traceContext.addTag(RequestId.Type.TRACE_ID, traceId2);
+      res.addHeader(X_GERRIT_TRACE, traceId2);
+    }
+    return traceContext;
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1187,12 +1377,30 @@
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
+  private static MethodNotAllowedException methodNotAllowed(HttpServletRequest req) {
+    return new MethodNotAllowedException(
+        String.format("Not implemented: %s %s", req.getMethod(), requestUri(req)));
+  }
+
+  private static String requestUri(HttpServletRequest req) {
+    String uri = req.getRequestURI();
+    if (uri.startsWith("/a/")) {
+      return uri.substring(2);
+    }
+    return uri;
+  }
+
   private void checkRequiresCapability(ViewData d)
       throws AuthException, PermissionBackendException {
-    globals
-        .permissionBackend
-        .currentUser()
-        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    try {
+      globals.permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException e) {
+      // Skiping
+      globals
+          .permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    }
   }
 
   private static long handleException(
@@ -1234,17 +1442,34 @@
     configureCaching(req, res, null, null, c);
     checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
     res.setStatus(statusCode);
-    return replyText(req, res, msg);
+    logger.atFinest().log("REST call failed: %d", statusCode);
+    return replyText(req, res, true, msg);
   }
 
-  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
+  /**
+   * Sets a text reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param text the text reply
+   * @return the length of the response
+   * @throws IOException
+   */
+  static long replyText(
+      @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
       throws IOException {
     if ((req == null || isRead(req)) && isMaybeHTML(text)) {
-      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
+      return replyJson(
+          req, res, allowTracing, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
     if (!text.endsWith("\n")) {
       text += "\n";
     }
+    if (allowTracing) {
+      logger.atFinest().log("Text response body:\n%s", text);
+    }
     return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 0ae9c4c..0bcd8f8 100644
--- a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -127,8 +127,12 @@
           replace(config, toDelete, section);
 
         } else if (AccessSection.isValid(name)) {
-          if (checkIfOwner && !forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
-            continue;
+          if (checkIfOwner) {
+            try {
+              forProject.ref(name).check(RefPermission.WRITE_CONFIG);
+            } catch (AuthException e) {
+              continue;
+            }
           }
 
           RefPattern.validate(name);
@@ -143,8 +147,15 @@
             config.remove(config.getAccessSection(name));
           }
 
-        } else if (!checkIfOwner || forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
+        } else if (!checkIfOwner) {
           config.remove(config.getAccessSection(name));
+        } else {
+          try {
+            forProject.ref(name).check(RefPermission.WRITE_CONFIG);
+            config.remove(config.getAccessSection(name));
+          } catch (AuthException e) {
+            // Do nothing.
+          }
         }
       }
 
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 5074350..d5517e1 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -12,19 +12,6 @@
 )
 
 java_library(
-    name = "query_parser",
-    srcs = ["//antlr3:query"],
-    visibility = [
-        "//javatests/com/google/gerrit/index:__pkg__",
-        "//plugins:__pkg__",
-    ],
-    deps = [
-        ":query_exception",
-        "//lib/antlr:java_runtime",
-    ],
-)
-
-java_library(
     name = "index",
     srcs = glob(
         ["**/*.java"],
@@ -33,14 +20,15 @@
     visibility = ["//visibility:public"],
     deps = [
         ":query_exception",
-        ":query_parser",
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
         "//lib:gwtjsonrpc",
         "//lib:gwtorm",
-        "//lib/antlr:java_runtime",
+        "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/index/RefState.java b/java/com/google/gerrit/index/RefState.java
similarity index 98%
rename from java/com/google/gerrit/server/index/RefState.java
rename to java/com/google/gerrit/index/RefState.java
index 6b893f0..f0e465d 100644
--- a/java/com/google/gerrit/server/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.index;
+package com.google.gerrit.index;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index 24b7a69..9c56396 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import java.io.OutputStream;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.RejectedExecutionException;
@@ -64,7 +66,7 @@
 
   protected int totalWork = -1;
   protected OutputStream progressOut = NullOutputStream.INSTANCE;
-  protected PrintWriter verboseWriter = new PrintWriter(NullOutputStream.INSTANCE);
+  protected PrintWriter verboseWriter = newPrintWriter(NullOutputStream.INSTANCE);
 
   public void setTotalWork(int num) {
     totalWork = num;
@@ -75,7 +77,7 @@
   }
 
   public void setVerboseOut(OutputStream out) {
-    verboseWriter = new PrintWriter(checkNotNull(out));
+    verboseWriter = newPrintWriter(checkNotNull(out));
   }
 
   public abstract Result indexAll(I index);
@@ -86,6 +88,10 @@
         new ErrorListener(future, desc, progress, ok), MoreExecutors.directExecutor());
   }
 
+  protected PrintWriter newPrintWriter(OutputStream out) {
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+  }
+
   private static class ErrorListener implements Runnable {
     private final ListenableFuture<?> future;
     private final String desc;
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index 9d6b571..fb029ac 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -14,23 +14,51 @@
 
 package com.google.gerrit.index.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.Project;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
 
 public class ProjectData {
   private final Project project;
-  private final ImmutableList<Project.NameKey> ancestors;
+  private final Optional<ProjectData> parent;
 
-  public ProjectData(Project project, Iterable<Project.NameKey> ancestors) {
+  public ProjectData(Project project, Optional<ProjectData> parent) {
     this.project = project;
-    this.ancestors = ImmutableList.copyOf(ancestors);
+    this.parent = parent;
   }
 
   public Project getProject() {
     return project;
   }
 
-  public ImmutableList<Project.NameKey> getAncestors() {
-    return ancestors;
+  public Optional<ProjectData> getParent() {
+    return parent;
+  }
+
+  /** Returns all {@link ProjectData} in the hierarchy starting with the current one. */
+  public ImmutableList<ProjectData> tree() {
+    List<ProjectData> parents = new ArrayList<>();
+    Optional<ProjectData> curr = Optional.of(this);
+    while (curr.isPresent()) {
+      parents.add(curr.get());
+      curr = curr.get().parent;
+    }
+    return ImmutableList.copyOf(parents);
+  }
+
+  public ImmutableList<String> getParentNames() {
+    return tree().stream().skip(1).map(p -> p.getProject().getName()).collect(toImmutableList());
+  }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(project.getName());
+    return h.toString();
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 1c2f629b..5e484b2 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -14,23 +14,30 @@
 
 package com.google.gerrit.index.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 
 /** Index schema for projects. */
 public class ProjectField {
+  private static byte[] toRefState(Project project) {
+    return RefState.create(RefNames.REFS_CONFIG, project.getConfigRefState())
+        .toByteArray(project.getNameKey());
+  }
 
   public static final FieldDef<ProjectData, String> NAME =
       exact("name").stored().build(p -> p.getProject().getName());
 
   public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").build(p -> p.getProject().getDescription());
+      fullText("description").stored().build(p -> p.getProject().getDescription());
 
   public static final FieldDef<ProjectData, String> PARENT_NAME =
       exact("parent_name").build(p -> p.getProject().getParentName());
@@ -38,7 +45,26 @@
   public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
       prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
 
+  public static final FieldDef<ProjectData, String> STATE =
+      exact("state").stored().build(p -> p.getProject().getState().name());
+
   public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name")
-          .buildRepeatable(p -> Iterables.transform(p.getAncestors(), Project.NameKey::get));
+      exact("ancestor_name").buildRepeatable(p -> p.getParentNames());
+
+  /**
+   * All values of all refs that were used in the course of indexing this document. This covers
+   * {@code refs/meta/config} of the current project and all of its parents.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<ProjectData, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              projectData ->
+                  projectData
+                      .tree()
+                      .stream()
+                      .filter(p -> p.getProject().getConfigRefState() != null)
+                      .map(p -> toRefState(p.getProject()))
+                      .collect(toImmutableList()));
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index cbea4fe..07b5adb 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -21,6 +21,7 @@
 
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
+  @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
           ProjectField.NAME,
@@ -29,6 +30,8 @@
           ProjectField.NAME_PART,
           ProjectField.ANCESTOR_NAME);
 
+  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+
   public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
 
   private ProjectSchemaDefinitions() {
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index e2605f4..d1e1c30 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index.query;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
@@ -26,7 +27,6 @@
 import com.google.gwtorm.server.ResultSet;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
@@ -175,10 +175,8 @@
     return cardinality;
   }
 
-  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
-    List<Predicate<T>> r = new ArrayList<>(that);
-    Collections.sort(r, this);
-    return r;
+  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    return that.stream().sorted(this).collect(toImmutableList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index ca74a52..53c92c9 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -105,6 +105,18 @@
     return getChildren().get(i);
   }
 
+  /** Get the number of leaf terms in this predicate. */
+  public int getLeafCount() {
+    int leafCount = 0;
+    for (Predicate<?> childPredicate : getChildren()) {
+      if (childPredicate instanceof IndexPredicate) {
+        leafCount++;
+      }
+      leafCount += childPredicate.getLeafCount();
+    }
+    return leafCount;
+  }
+
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index b318199..1a42ebd 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -22,11 +22,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.Description;
@@ -38,6 +40,7 @@
 import com.google.gwtorm.server.ResultSet;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -51,6 +54,8 @@
  * holding on to a single instance.
  */
 public abstract class QueryProcessor<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected static class Metrics {
     final Timer1<String> executionTime;
 
@@ -199,58 +204,76 @@
       return disabledResults(queryStrings, queries);
     }
 
-    // Parse and rewrite all queries.
-    List<Integer> limits = new ArrayList<>(cnt);
-    List<Predicate<T>> predicates = new ArrayList<>(cnt);
-    List<DataSource<T>> sources = new ArrayList<>(cnt);
-    for (Predicate<T> q : queries) {
-      int limit = getEffectiveLimit(q);
-      limits.add(limit);
+    List<QueryResult<T>> out;
+    try {
+      // Parse and rewrite all queries.
+      List<Integer> limits = new ArrayList<>(cnt);
+      List<Predicate<T>> predicates = new ArrayList<>(cnt);
+      List<DataSource<T>> sources = new ArrayList<>(cnt);
+      int queryCount = 0;
+      for (Predicate<T> q : queries) {
+        int limit = getEffectiveLimit(q);
+        limits.add(limit);
 
-      if (limit == getBackendSupportedLimit()) {
-        limit--;
+        if (limit == getBackendSupportedLimit()) {
+          limit--;
+        }
+
+        int page = (start / limit) + 1;
+        if (page > indexConfig.maxPages()) {
+          throw new QueryParseException(
+              "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+        }
+
+        // Always bump limit by 1, even if this results in exceeding the permitted
+        // max for this user. The only way to see if there are more entities is to
+        // ask for one more result from the query.
+        QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
+        logger.atFine().log("Query options: " + opts);
+        Predicate<T> pred = rewriter.rewrite(q, opts);
+        if (enforceVisibility) {
+          pred = enforceVisibility(pred);
+        }
+        predicates.add(pred);
+        logger.atFine().log(
+            "%s index query[%d]:\n%s",
+            schemaDef.getName(),
+            queryCount++,
+            pred instanceof IndexedQuery ? pred.getChild(0) : pred);
+
+        @SuppressWarnings("unchecked")
+        DataSource<T> s = (DataSource<T>) pred;
+        sources.add(s);
       }
 
-      int page = (start / limit) + 1;
-      if (page > indexConfig.maxPages()) {
-        throw new QueryParseException(
-            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+      // Run each query asynchronously, if supported.
+      List<ResultSet<T>> matches = new ArrayList<>(cnt);
+      for (DataSource<T> s : sources) {
+        matches.add(s.read());
       }
 
-      // Always bump limit by 1, even if this results in exceeding the permitted
-      // max for this user. The only way to see if there are more entities is to
-      // ask for one more result from the query.
-      QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
-      Predicate<T> pred = rewriter.rewrite(q, opts);
-      if (enforceVisibility) {
-        pred = enforceVisibility(pred);
+      out = new ArrayList<>(cnt);
+      for (int i = 0; i < cnt; i++) {
+        List<T> matchesList = matches.get(i).toList();
+        logger.atFine().log("Matches[%d]:\n%s", i, matchesList);
+        out.add(
+            QueryResult.create(
+                queryStrings != null ? queryStrings.get(i) : null,
+                predicates.get(i),
+                limits.get(i),
+                matchesList));
       }
-      predicates.add(pred);
 
-      @SuppressWarnings("unchecked")
-      DataSource<T> s = (DataSource<T>) pred;
-      sources.add(s);
+      // Only measure successful queries that actually touched the index.
+      metrics.executionTime.record(
+          schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+    } catch (OrmException | OrmRuntimeException e) {
+      Optional<QueryParseException> qpe = findQueryParseException(e);
+      if (qpe.isPresent()) {
+        throw new QueryParseException(qpe.get().getMessage(), e);
+      }
+      throw e;
     }
-
-    // Run each query asynchronously, if supported.
-    List<ResultSet<T>> matches = new ArrayList<>(cnt);
-    for (DataSource<T> s : sources) {
-      matches.add(s.read());
-    }
-
-    List<QueryResult<T>> out = new ArrayList<>(cnt);
-    for (int i = 0; i < cnt; i++) {
-      out.add(
-          QueryResult.create(
-              queryStrings != null ? queryStrings.get(i) : null,
-              predicates.get(i),
-              limits.get(i),
-              matches.get(i).toList()));
-    }
-
-    // Only measure successful queries that actually touched the index.
-    metrics.executionTime.record(
-        schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
     return out;
   }
 
@@ -332,4 +355,12 @@
     checkState(result > 0, "effective limit should be positive");
     return result;
   }
+
+  private static Optional<QueryParseException> findQueryParseException(Throwable t) {
+    return Throwables.getCausalChain(t)
+        .stream()
+        .filter(c -> c instanceof QueryParseException)
+        .map(QueryParseException.class::cast)
+        .findFirst();
+  }
 }
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 13dad0e..0d26fe7 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -34,6 +34,7 @@
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.CodeSource;
@@ -44,6 +45,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import java.util.Scanner;
 import java.util.SortedMap;
 import java.util.TreeMap;
@@ -644,6 +646,25 @@
     return resolveInSourceRoot("eclipse-out");
   }
 
+  public static boolean isJdk9OrLater() {
+    return Double.parseDouble(System.getProperty("java.class.version")) >= 53.0;
+  }
+
+  public static String getJdkVersionPostJdk8() {
+    // 9.0.4 => 9
+    return System.getProperty("java.version").substring(0, 1);
+  }
+
+  public 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;
+  }
+
   static final String SOURCE_ROOT_RESOURCE = "/com/google/gerrit/launcher/workspace-root.txt";
 
   /**
@@ -708,14 +729,36 @@
     return ret;
   }
 
-  private static ClassLoader useDevClasspath() throws MalformedURLException, FileNotFoundException {
+  private static ClassLoader useDevClasspath() throws IOException {
     Path out = getDeveloperEclipseOut();
     List<URL> dirs = new ArrayList<>();
     dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
-    for (URL u : ((URLClassLoader) cl).getURLs()) {
-      if (includeJar(u)) {
-        dirs.add(u);
+
+    if (isJdk9OrLater()) {
+      Path rootPath = resolveInSourceRoot(".").normalize();
+
+      Properties properties = loadBuildProperties(rootPath.resolve(".bazel_path"));
+      Path outputBase = Paths.get(properties.getProperty("output_base"));
+
+      Path runtimeClasspath =
+          rootPath.resolve("bazel-bin/tools/eclipse/main_classpath_collect.runtime_classpath");
+      for (String f : Files.readAllLines(runtimeClasspath, UTF_8)) {
+        URL url;
+        if (f.startsWith("external")) {
+          url = outputBase.resolve(f).toUri().toURL();
+        } else {
+          url = rootPath.resolve(f).toUri().toURL();
+        }
+        if (includeJar(url)) {
+          dirs.add(url);
+        }
+      }
+    } else {
+      for (URL u : ((URLClassLoader) cl).getURLs()) {
+        if (includeJar(u)) {
+          dirs.add(u);
+        }
       }
     }
     return URLClassLoader.newInstance(
@@ -724,7 +767,9 @@
 
   private static boolean includeJar(URL u) {
     String path = u.getPath();
-    return path.endsWith(".jar") && !path.endsWith("-src.jar");
+    return path.endsWith(".jar")
+        && !path.endsWith("-src.jar")
+        && !path.contains("/com/google/gerrit");
   }
 
   private GerritLauncher() {}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 3871ced..dc293cd 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -39,6 +39,8 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import java.io.IOException;
@@ -54,6 +56,7 @@
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -99,7 +102,7 @@
   private final ReferenceManager<IndexSearcher> searcherManager;
   private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
   private final Set<NrtFuture> notDoneNrtFutures;
-  private ScheduledThreadPoolExecutor autoCommitExecutor;
+  private ScheduledExecutorService autoCommitExecutor;
 
   AbstractLuceneIndex(
       Schema<V> schema,
@@ -128,12 +131,13 @@
       delegateWriter = autoCommitWriter;
 
       autoCommitExecutor =
-          new ScheduledThreadPoolExecutor(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat(index + " Commit-%d")
-                  .setDaemon(true)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              new ScheduledThreadPoolExecutor(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat(index + " Commit-%d")
+                      .setDaemon(true)
+                      .build()));
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
@@ -168,12 +172,13 @@
 
     writerThread =
         MoreExecutors.listeningDecorator(
-            Executors.newFixedThreadPool(
-                1,
-                new ThreadFactoryBuilder()
-                    .setNameFormat(index + " Write-%d")
-                    .setDaemon(true)
-                    .build()));
+            new LoggingContextAwareExecutorService(
+                Executors.newFixedThreadPool(
+                    1,
+                    new ThreadFactoryBuilder()
+                        .setNameFormat(index + " Write-%d")
+                        .setDaemon(true)
+                        .build())));
 
     reopenThread =
         new ControlledRealTimeReopenThread<>(
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 6cb7751..9c6ba74 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -33,6 +33,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/mail/Address.java b/java/com/google/gerrit/mail/Address.java
similarity index 95%
rename from java/com/google/gerrit/server/mail/Address.java
rename to java/com/google/gerrit/mail/Address.java
index e91f3f3..24ab353 100644
--- a/java/com/google/gerrit/server/mail/Address.java
+++ b/java/com/google/gerrit/mail/Address.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.mail;
 
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.common.Nullable;
 
 public class Address {
   public static Address parse(String in) {
@@ -50,8 +50,8 @@
     }
   }
 
-  final String name;
-  final String email;
+  @Nullable private final String name;
+  private final String email;
 
   public Address(String email) {
     this(null, email);
@@ -62,6 +62,7 @@
     this.email = email;
   }
 
+  @Nullable
   public String getName() {
     return name;
   }
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
new file mode 100644
index 0000000..90bb82c
--- /dev/null
+++ b/java/com/google/gerrit/mail/BUILD
@@ -0,0 +1,18 @@
+java_library(
+    name = "mail",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
+        "//lib/jsoup",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+    ],
+)
diff --git a/java/com/google/gerrit/server/mail/send/EmailHeader.java b/java/com/google/gerrit/mail/EmailHeader.java
similarity index 97%
rename from java/com/google/gerrit/server/mail/send/EmailHeader.java
rename to java/com/google/gerrit/mail/EmailHeader.java
index 29354f2..69d5fcd 100644
--- a/java/com/google/gerrit/server/mail/send/EmailHeader.java
+++ b/java/com/google/gerrit/mail/EmailHeader.java
@@ -12,12 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.send;
+package com.google.gerrit.mail;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.server.mail.Address;
 import java.io.IOException;
 import java.io.Writer;
 import java.text.SimpleDateFormat;
@@ -183,7 +182,7 @@
       list.add(addr);
     }
 
-    void remove(java.lang.String email) {
+    public void remove(java.lang.String email) {
       list.removeIf(address -> address.getEmail().equals(email));
     }
 
diff --git a/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
similarity index 98%
rename from java/com/google/gerrit/server/mail/receive/HtmlParser.java
rename to java/com/google/gerrit/mail/HtmlParser.java
index d68f076..9821599 100644
--- a/java/com/google/gerrit/server/mail/receive/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
diff --git a/java/com/google/gerrit/server/mail/receive/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
similarity index 84%
rename from java/com/google/gerrit/server/mail/receive/MailComment.java
rename to java/com/google/gerrit/mail/MailComment.java
index 8571e12..fd8198c 100644
--- a/java/com/google/gerrit/server/mail/receive/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -12,14 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.gerrit.reviewdb.client.Comment;
 import java.util.Objects;
 
 /** A comment parsed from inbound email */
 public class MailComment {
-  enum CommentType {
+  public enum CommentType {
     CHANGE_MESSAGE,
     FILE_COMMENT,
     INLINE_COMMENT
@@ -42,6 +42,22 @@
     this.isLink = isLink;
   }
 
+  public CommentType getType() {
+    return type;
+  }
+
+  public Comment getInReplyTo() {
+    return inReplyTo;
+  }
+
+  public String getFileName() {
+    return fileName;
+  }
+
+  public String getMessage() {
+    return message;
+  }
+
   /**
    * Checks if the provided comment concerns the same exact spot in the change. This is basically an
    * equals method except that the message is not checked.
diff --git a/java/com/google/gerrit/server/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
similarity index 97%
rename from java/com/google/gerrit/server/mail/MailHeader.java
rename to java/com/google/gerrit/mail/MailHeader.java
index cf145e5..2f31a9c 100644
--- a/java/com/google/gerrit/server/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.mail;
 
 /** Variables used by emails to hold data */
 public enum MailHeader {
diff --git a/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java b/java/com/google/gerrit/mail/MailHeaderParser.java
similarity index 92%
rename from java/com/google/gerrit/server/mail/receive/MailHeaderParser.java
rename to java/com/google/gerrit/mail/MailHeaderParser.java
index d176095..8eb4d97 100644
--- a/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java
+++ b/java/com/google/gerrit/mail/MailHeaderParser.java
@@ -12,14 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.MailUtil;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.time.format.DateTimeParseException;
@@ -44,7 +42,8 @@
       } else if (header.startsWith(MailHeader.COMMENT_DATE.fieldWithDelimiter())) {
         String ts = header.substring(MailHeader.COMMENT_DATE.fieldWithDelimiter().length()).trim();
         try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+          metadata.timestamp =
+              Timestamp.from(MailProcessingUtil.rfcDateformatter.parse(ts, Instant::from));
         } catch (DateTimeParseException e) {
           logger.atSevere().withCause(e).log(
               "Mail: Error while parsing timestamp from header of message %s", m.id());
@@ -91,7 +90,8 @@
       } else if (metadata.timestamp == null && line.contains(MailHeader.COMMENT_DATE.getName())) {
         String ts = extractFooter(MailHeader.COMMENT_DATE.withDelimiter(), line);
         try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+          metadata.timestamp =
+              Timestamp.from(MailProcessingUtil.rfcDateformatter.parse(ts, Instant::from));
         } catch (DateTimeParseException e) {
           logger.atSevere().withCause(e).log(
               "Mail: Error while parsing timestamp from footer of message %s", m.id());
diff --git a/java/com/google/gerrit/server/mail/receive/MailMessage.java b/java/com/google/gerrit/mail/MailMessage.java
similarity index 96%
rename from java/com/google/gerrit/server/mail/receive/MailMessage.java
rename to java/com/google/gerrit/mail/MailMessage.java
index 0d20464..bb83dfd 100644
--- a/java/com/google/gerrit/server/mail/receive/MailMessage.java
+++ b/java/com/google/gerrit/mail/MailMessage.java
@@ -12,12 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.mail.Address;
 import java.time.Instant;
 
 /**
diff --git a/java/com/google/gerrit/server/mail/receive/MailMetadata.java b/java/com/google/gerrit/mail/MailMetadata.java
similarity index 96%
rename from java/com/google/gerrit/server/mail/receive/MailMetadata.java
rename to java/com/google/gerrit/mail/MailMetadata.java
index 04c2add..a311461 100644
--- a/java/com/google/gerrit/server/mail/receive/MailMetadata.java
+++ b/java/com/google/gerrit/mail/MailMetadata.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.common.base.MoreObjects;
 import java.sql.Timestamp;
diff --git a/java/com/google/gerrit/server/mail/receive/MailParsingException.java b/java/com/google/gerrit/mail/MailParsingException.java
similarity index 94%
rename from java/com/google/gerrit/server/mail/receive/MailParsingException.java
rename to java/com/google/gerrit/mail/MailParsingException.java
index b91bb18..7e85a27 100644
--- a/java/com/google/gerrit/server/mail/receive/MailParsingException.java
+++ b/java/com/google/gerrit/mail/MailParsingException.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 /** An {@link Exception} indicating that an email could not be parsed. */
 public class MailParsingException extends Exception {
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/mail/MailProcessingUtil.java
similarity index 65%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to java/com/google/gerrit/mail/MailProcessingUtil.java
index 13d2b9d..b63189a 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/java/com/google/gerrit/mail/MailProcessingUtil.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.mail;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.time.format.DateTimeFormatter;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+public class MailProcessingUtil {
+
+  public static DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 }
diff --git a/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
similarity index 98%
rename from java/com/google/gerrit/server/mail/receive/ParserUtil.java
rename to java/com/google/gerrit/mail/ParserUtil.java
index e770a3e..6a27ac4 100644
--- a/java/com/google/gerrit/server/mail/receive/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
diff --git a/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
similarity index 98%
rename from java/com/google/gerrit/server/mail/receive/RawMailParser.java
rename to java/com/google/gerrit/mail/RawMailParser.java
index 57fe21f..00754d3 100644
--- a/java/com/google/gerrit/server/mail/receive/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -21,7 +21,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.CharStreams;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.Address;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
diff --git a/java/com/google/gerrit/server/mail/receive/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
similarity index 98%
rename from java/com/google/gerrit/server/mail/receive/TextParser.java
rename to java/com/google/gerrit/mail/TextParser.java
index b99c608..1a63599 100644
--- a/java/com/google/gerrit/server/mail/receive/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index ea408e2..d3c030d 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -68,7 +68,7 @@
 
   @Override
   public Timer0 newTimer(String name, Description desc) {
-    return new Timer0() {
+    return new Timer0(name) {
       @Override
       public void record(long value, TimeUnit unit) {}
 
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name) {
       @Override
       public void record(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>() {
+    return new Timer2<F1, F2>(name) {
       @Override
       public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>() {
+    return new Timer3<F1, F2, F3>(name) {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 55d1ddf..225b76f 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -31,6 +32,8 @@
  */
 public abstract class Timer0 implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer0 timer;
 
     Context(Timer0 timer) {
@@ -39,10 +42,17 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log("%s took %dms", timer.name, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer0(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index f623841..0db0353 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -33,6 +34,8 @@
  */
 public abstract class Timer1<F1> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer1<Object> timer;
     private final Object field1;
 
@@ -44,10 +47,18 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s) took %dms", timer.name, field1, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer1(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index b03ff83..cfdfb7a 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -34,6 +35,8 @@
  */
 public abstract class Timer2<F1, F2> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer2<Object, Object> timer;
     private final Object field1;
     private final Object field2;
@@ -47,10 +50,19 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s, %s) took %dms",
+          timer.name, field1, field2, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, field2, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer2(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 91af42c..1711445 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -35,6 +36,8 @@
  */
 public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer3<Object, Object, Object> timer;
     private final Object field1;
     private final Object field2;
@@ -50,10 +53,19 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s, %s, %s) took %dms",
+          timer.name, field1, field2, field3, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, field2, field3, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer3(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
index 3b19a62..a7ffe07 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -26,7 +26,7 @@
 /** Abstract timer broken down into buckets by {@link Field} values. */
 abstract class BucketedTimer implements BucketedMetric {
   private final DropWizardMetricMaker metrics;
-  private final String name;
+  protected final String name;
   private final Description.FieldOrdering ordering;
   protected final Field<?>[] fields;
   protected final TimerImpl total;
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index fc53ee7..ead718f 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -393,11 +393,10 @@
   }
 
   class TimerImpl extends Timer0 {
-    private final String name;
     final com.codahale.metrics.Timer metric;
 
     private TimerImpl(String name, com.codahale.metrics.Timer metric) {
-      this.name = name;
+      super(name);
       this.metric = metric;
     }
 
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index fe6f70e..fc4ba3f 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -27,7 +27,7 @@
   }
 
   Timer1<F1> timer() {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name) {
       @Override
       public void record(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index 43cc290..d04a65e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -30,7 +30,7 @@
   }
 
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>() {
+    return new Timer2<F1, F2>(name) {
       @Override
       public void record(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -45,7 +45,7 @@
   }
 
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>() {
+    return new Timer3<F1, F2, F3>(name) {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index c83f8af..ba27991 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -36,14 +36,15 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/sshd",
-        "//java/com/google/gwtexpui/linker:server",
-        "//java/com/google/gwtexpui/server",
+        "//java/com/google/gerrit/util/http",
         "//lib:args4j",
         "//lib:guava",
         "//lib:gwtorm",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index c38e7f5..1a9af55 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GerritAuthModule;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -56,6 +57,7 @@
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -422,6 +424,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
+    modules.add(new AuditModule());
 
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
@@ -573,10 +576,11 @@
       modules.add(new ProjectQoSFilter.Module());
     }
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
     modules.add(H2CacheBasedWebSession.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     modules.add(new HttpPluginModule());
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index b9c7068..a93e64c 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.pgm.util.ErrorLogFile;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gerrit.server.util.HostPlatform;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
index 0b44ccf..61d7ed9 100644
--- a/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
@@ -34,10 +35,12 @@
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.notedb.rebuild.GcAllUsers;
 import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
@@ -118,6 +121,9 @@
       sysInjector.injectMembers(this);
       sysManager = new LifecycleManager();
       sysManager.add(sysInjector);
+      sysInjector
+          .getInstance(PluginGuiceEnvironment.class)
+          .setDbCfgInjector(dbInjector, dbInjector);
       sysManager.start();
 
       try (NoteDbMigrator migrator =
@@ -137,7 +143,7 @@
           migrator.migrate();
         }
       }
-      try (PrintWriter w = new PrintWriter(System.out, true)) {
+      try (PrintWriter w = new PrintWriter(new OutputStreamWriter(System.out, UTF_8), true)) {
         gcAllUsers.run(w);
       }
     } finally {
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 2a34efa..d26e4f71 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -72,6 +73,7 @@
 
   private Injector dbInjector;
   private Injector sysInjector;
+  private Injector cfgInjector;
   private Config globalConfig;
 
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
@@ -80,6 +82,7 @@
   public int run() throws Exception {
     mustHaveValidSite();
     dbInjector = createDbInjector(MULTI_USER);
+    cfgInjector = dbInjector.createChildInjector();
     globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     threads = ThreadLimiter.limitThreads(dbInjector, threads);
     overrideConfig();
@@ -88,9 +91,11 @@
     dbManager.start();
 
     sysInjector = createSysInjector();
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
     LifecycleManager sysManager = new LifecycleManager();
     sysManager.add(sysInjector);
     sysManager.start();
+
     sysInjector.injectMembers(this);
     checkIndicesOption();
 
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
index 6dc6365..b1da011 100644
--- a/java/com/google/gerrit/pgm/http/jetty/BUILD
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -10,7 +10,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/sshd",
-        "//java/com/google/gwtexpui/server",
+        "//java/com/google/gerrit/util/http",
         "//lib:guava",
         "//lib:servlet-api-3_1",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java b/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
index 9347171..1c43240 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
@@ -18,7 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gerrit.util.http.CacheHeaders;
 import java.io.IOException;
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServletRequest;
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 9354209..96cf7be 100644
--- a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -61,7 +61,6 @@
  * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
  * resume processing on the web service thread.
  */
-@SuppressWarnings("deprecation")
 @Singleton
 public class ProjectQoSFilter implements Filter {
   private static final String ATT_SPACE = ProjectQoSFilter.class.getName();
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index c781a60..8a1c08b 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/schema",
         "//lib:guava",
         "//lib:gwtjsonrpc",
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 707802e..6336c93 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -39,13 +39,13 @@
 public class ExternalIdsOnInit {
   private final InitFlags flags;
   private final SitePaths site;
-  private final String allUsers;
+  private final AllUsersName allUsers;
 
   @Inject
   public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
     this.flags = flags;
     this.site = site;
-    this.allUsers = allUsers.get();
+    this.allUsers = new AllUsersName(allUsers.get());
   }
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
@@ -53,11 +53,10 @@
     File path = getPath();
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersRepo);
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, allUsersRepo);
         extIdNotes.insert(extIds);
         try (MetaDataUpdate metaDataUpdate =
-            new MetaDataUpdate(
-                GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), allUsersRepo)) {
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, allUsersRepo)) {
           PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
           metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
           metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
@@ -73,6 +72,6 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    return FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 8fc9119..8e06aa1 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -64,13 +64,13 @@
 
   private final InitFlags flags;
   private final SitePaths site;
-  private final String allUsers;
+  private final AllUsersName allUsers;
 
   @Inject
   public GroupsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
     this.flags = flags;
     this.site = site;
-    this.allUsers = allUsers.get();
+    this.allUsers = new AllUsersName(allUsers.get());
   }
 
   /**
@@ -90,7 +90,7 @@
     if (allUsersRepoPath != null) {
       try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
         AccountGroup.UUID groupUuid = groupReference.getUUID();
-        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsers, allUsersRepo, groupUuid);
         return groupConfig
             .getLoadedGroup()
             .orElseThrow(() -> new NoSuchGroupException(groupReference.getUUID()));
@@ -145,7 +145,7 @@
   private void addGroupMemberInNoteDb(
       Repository repository, AccountGroup.UUID groupUuid, Account account)
       throws IOException, ConfigInvalidException, NoSuchGroupException {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsers, repository, groupUuid);
     InternalGroup group =
         groupConfig.getLoadedGroup().orElseThrow(() -> new NoSuchGroupException(groupUuid));
 
@@ -160,7 +160,7 @@
   private File getPathToAllUsersRepository() {
     Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
     checkArgument(basePath != null, "gerrit.basePath must be configured");
-    return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 
   private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
@@ -186,7 +186,7 @@
 
   private MetaDataUpdate createMetaDataUpdate(Repository repository, PersonIdent personIdent) {
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), repository);
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repository);
     metaDataUpdate.getCommitBuilder().setAuthor(personIdent);
     metaDataUpdate.getCommitBuilder().setCommitter(personIdent);
     return metaDataUpdate;
diff --git a/java/com/google/gerrit/pgm/init/InitExperimental.java b/java/com/google/gerrit/pgm/init/InitExperimental.java
deleted file mode 100644
index b2cced9..0000000
--- a/java/com/google/gerrit/pgm/init/InitExperimental.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2017 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.pgm.init;
-
-import com.google.gerrit.extensions.client.UiType;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Locale;
-
-@Singleton
-class InitExperimental implements InitStep {
-  private final ConsoleUI ui;
-  private final Section gerrit;
-
-  @Inject
-  InitExperimental(ConsoleUI ui, Section.Factory sections) {
-    this.ui = ui;
-    this.gerrit = sections.get("gerrit", null);
-  }
-
-  @Override
-  public void run() {
-    ui.header("Experimental features");
-    if (!ui.yesno(false, "Enable any experimental features")) {
-      return;
-    }
-
-    initUis();
-  }
-
-  private void initUis() {
-    boolean pg = ui.yesno(true, "Default to PolyGerrit UI");
-    UiType uiType = pg ? UiType.POLYGERRIT : UiType.GWT;
-    gerrit.set("ui", uiType.name().toLowerCase(Locale.US));
-    if (pg) {
-      gerrit.set("enableGwtUi", Boolean.toString(ui.yesno(true, "Enable GWT UI")));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index ee6c440..0de08f2 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -63,13 +62,7 @@
     if (type == IndexType.ELASTICSEARCH) {
       Section elasticsearch = sections.get("elasticsearch", null);
       elasticsearch.string("Index Prefix", "prefix", "gerrit_");
-      String name = ui.readString("default", "Server Name");
-
-      Section defaultServer = sections.get("elasticsearch", name);
-      defaultServer.select(
-          "Transport protocol", "protocol", "http", Sets.newHashSet("http", "https"));
-      defaultServer.string("Hostname", "hostname", "localhost");
-      defaultServer.string("Port", "port", "9200");
+      elasticsearch.string("Server", "server", "http://localhost:9200");
       index.string("Result window size", "maxLimit", "10000");
     }
 
diff --git a/java/com/google/gerrit/pgm/init/InitLogging.java b/java/com/google/gerrit/pgm/init/InitLogging.java
new file mode 100644
index 0000000..52d0d2f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitLogging.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 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.pgm.init;
+
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class InitLogging implements InitStep {
+  private static final String CONTAINER = "container";
+  private static final String JAVA_OPTIONS = "javaOptions";
+  private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
+
+  private final Section container;
+
+  @Inject
+  public InitLogging(Section.Factory sections) {
+    this.container = sections.get(CONTAINER, null);
+  }
+
+  @Override
+  public void run() throws Exception {
+    List<String> javaOptions = new ArrayList<>(Arrays.asList(container.getList(JAVA_OPTIONS)));
+    if (!isSet(javaOptions, FLOGGER_BACKEND_PROPERTY)) {
+      javaOptions.add(
+          getJavaOption(
+              FLOGGER_BACKEND_PROPERTY,
+              "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance"));
+    }
+    if (!isSet(javaOptions, FLOGGER_LOGGING_CONTEXT)) {
+      javaOptions.add(
+          getJavaOption(
+              FLOGGER_LOGGING_CONTEXT,
+              "com.google.gerrit.server.logging.LoggingContext#getInstance"));
+    }
+    container.setList(JAVA_OPTIONS, javaOptions);
+  }
+
+  private static boolean isSet(List<String> javaOptions, String javaOptionName) {
+    return javaOptions
+        .stream()
+        .anyMatch(
+            o ->
+                o.startsWith("-D" + javaOptionName + "=")
+                    || o.startsWith("\"-D" + javaOptionName + "="));
+  }
+
+  private static String getJavaOption(String javaOptionName, String value) {
+    return String.format("-D%s=%s", javaOptionName, value);
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index f75d2dc..f677ceb 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -49,6 +49,7 @@
     if (initDb) {
       step().to(InitDatabase.class);
     }
+    step().to(InitLogging.class);
     step().to(InitIndex.class);
     step().to(InitAuth.class);
     step().to(InitAdminUser.class);
@@ -62,7 +63,6 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
-    step().to(InitExperimental.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/java/com/google/gerrit/pgm/init/InitPlugins.java b/java/com/google/gerrit/pgm/init/InitPlugins.java
index 385d20c..e43114c 100644
--- a/java/com/google/gerrit/pgm/init/InitPlugins.java
+++ b/java/com/google/gerrit/pgm/init/InitPlugins.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.pgm.init;
 
-import com.google.common.collect.FluentIterable;
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.common.PluginData;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -25,12 +26,10 @@
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -58,25 +57,16 @@
       throws IOException {
     final List<PluginData> result = new ArrayList<>();
     pluginsDistribution.foreach(
-        new PluginsDistribution.Processor() {
-          @Override
-          public void process(String pluginName, InputStream in) throws IOException {
-            Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
-            String pluginVersion = getVersion(tmpPlugin);
-            if (deleteTempPluginFile) {
-              Files.delete(tmpPlugin);
-            }
-            result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
+        (pluginName, in) -> {
+          Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
+          String pluginVersion = getVersion(tmpPlugin);
+          if (deleteTempPluginFile) {
+            Files.delete(tmpPlugin);
           }
+          result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
         });
-    return FluentIterable.from(result)
-        .toSortedList(
-            new Comparator<PluginData>() {
-              @Override
-              public int compare(PluginData a, PluginData b) {
-                return a.name.compareTo(b.name);
-              }
-            });
+    result.sort(comparing(p -> p.name));
+    return result;
   }
 
   private final ConsoleUI ui;
diff --git a/java/com/google/gerrit/pgm/init/InitSshd.java b/java/com/google/gerrit/pgm/init/InitSshd.java
index d2e280d..68bdefc 100644
--- a/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -103,7 +103,7 @@
                 "-q" /* quiet */,
                 "-t",
                 "rsa",
-                "-P",
+                "-N",
                 emptyPassphraseArg,
                 "-C",
                 comment,
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index 009e989..baf37b6 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -23,6 +23,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
+import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 
@@ -59,6 +60,10 @@
     return flags.cfg.getString(section, subsection, name);
   }
 
+  public String[] getList(String name) {
+    return flags.cfg.getStringList(section, subsection, name);
+  }
+
   public void set(String name, String value) {
     final ArrayList<String> all = new ArrayList<>();
     all.addAll(Arrays.asList(flags.cfg.getStringList(section, subsection, name)));
@@ -79,6 +84,10 @@
     }
   }
 
+  public void setList(String name, List<String> values) {
+    flags.cfg.setStringList(section, subsection, name, values);
+  }
+
   public <T extends Enum<?>> void set(String name, T value) {
     if (value != null) {
       set(name, value.name());
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index e3b95ee..738cafd 100644
--- a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -57,7 +58,7 @@
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path)) {
-        load(repo);
+        load(new Project.NameKey(project), repo);
       }
     }
     return this;
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index ecaadba..683a205 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.permissions.SectionSortCache;
+import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.ProjectCacheImpl;
@@ -74,6 +75,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -111,7 +113,16 @@
     install(BatchUpdate.module());
     install(PatchListCacheImpl.module());
 
-    // Plugins are not loaded and we're just running through each change
+    // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
+    //  listener().to(SomeClassImplementingLifecycleListener.class);
+    // and the start() methods of each such listener are executed in the order they are declared.
+    // Makes sure that PluginLoader.start() is executed before the LuceneIndexModule.start() so that
+    // plugins get loaded and the respective Guice modules installed so that the on-line reindexing
+    // will happen with the proper classes (e.g. group backends, custom Prolog predicates) and the
+    // associated rules ready to be evaluated.
+    install(new PluginModule());
+
+    // We're just running through each change
     // once, so don't worry about cache removal.
     bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {})
         .toInstance(DynamicSet.<CacheRemovalListener>emptySet());
@@ -178,6 +189,7 @@
     factory(SubmitRuleEvaluator.Factory.class);
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
 
     bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
     bind(EventUtil.class).toProvider(Providers.<EventUtil>of(null));
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 057496f..1338efb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -64,6 +64,8 @@
 import org.kohsuke.args4j.Option;
 
 public abstract class SiteProgram extends AbstractProgram {
+  private static final String CONNECTION_ERROR = "Cannot connect to SQL database";
+
   @Option(
       name = "--site-path",
       aliases = {"-d"},
@@ -106,14 +108,13 @@
 
   /** @return provides database connectivity and site path. */
   protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
-    Path sitePath = getSitePath();
     List<Module> modules = new ArrayList<>();
 
     Module sitePathModule =
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
             bind(String.class)
                 .annotatedWith(SecureStoreClassName.class)
                 .toProvider(Providers.of(getConfiguredSecureStoreClass()));
@@ -198,16 +199,16 @@
       Throwable why = first.getCause();
 
       if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
       if (why instanceof OrmException
           && why.getCause() != null
           && "Unable to determine driver URL".equals(why.getMessage())) {
         why = why.getCause();
         if (isCannotCreatePoolException(why)) {
-          throw die("Cannot connect to SQL database", why.getCause());
+          throw die(CONNECTION_ERROR, why.getCause());
         }
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
 
       StringBuilder buf = new StringBuilder();
@@ -259,8 +260,9 @@
     for (Binding<DataSourceType> binding : dsTypeBindings) {
       Annotation annotation = binding.getKey().getAnnotation();
       if (annotation instanceof Named) {
-        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
-          return ((Named) annotation).value();
+        Named named = (Named) annotation;
+        if (named.value().toLowerCase().contains(dbProductName)) {
+          return named.value();
         }
       }
     }
diff --git a/java/com/google/gerrit/proto/BUILD b/java/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..48185d6
--- /dev/null
+++ b/java/com/google/gerrit/proto/BUILD
@@ -0,0 +1,14 @@
+java_binary(
+    name = "ProtoGen",
+    srcs = ["ProtoGen.java"],
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/proto"],
+    visibility = ["//proto:__pkg__"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/pgm/ProtoGen.java b/java/com/google/gerrit/proto/ProtoGen.java
similarity index 72%
rename from java/com/google/gerrit/pgm/ProtoGen.java
rename to java/com/google/gerrit/proto/ProtoGen.java
index a882412..4a1598b 100644
--- a/java/com/google/gerrit/pgm/ProtoGen.java
+++ b/java/com/google/gerrit/proto/ProtoGen.java
@@ -12,11 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.pgm;
+package com.google.gerrit.proto;
 
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.pgm.util.AbstractProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.schema.java.JavaSchemaModel;
 import java.io.BufferedWriter;
@@ -28,9 +28,11 @@
 import java.nio.ByteBuffer;
 import org.eclipse.jgit.internal.storage.file.LockFile;
 import org.eclipse.jgit.util.IO;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
 
-public class ProtoGen extends AbstractProgram {
+public class ProtoGen {
   @Option(
       name = "--output",
       aliases = {"-o"},
@@ -39,12 +41,23 @@
       usage = "File to write .proto into")
   private File file;
 
-  @Override
-  public int run() throws Exception {
-    LockFile lock = new LockFile(file.getAbsoluteFile());
-    if (!lock.lock()) {
-      throw die("Cannot lock " + file);
+  public static void main(String[] argv) throws Exception {
+    System.exit(new ProtoGen().run(argv));
+  }
+
+  private int run(String[] argv) throws Exception {
+    CmdLineParser parser = new CmdLineParser(this);
+    try {
+      parser.parseArgument(argv);
+    } catch (CmdLineException e) {
+      System.err.println(e.getMessage());
+      System.err.println(getClass().getSimpleName() + " -o output.proto");
+      parser.printUsage(System.err);
+      return 1;
     }
+
+    LockFile lock = new LockFile(file.getAbsoluteFile());
+    checkState(lock.lock(), "cannot lock %s", file);
     try {
       JavaSchemaModel jsm = new JavaSchemaModel(ReviewDb.class);
       try (OutputStream o = lock.getOutputStream();
@@ -61,9 +74,7 @@
         jsm.generateProto(out);
         out.flush();
       }
-      if (!lock.commit()) {
-        throw die("Could not write to " + file);
-      }
+      checkState(lock.commit(), "Could not write to %s", file);
     } finally {
       lock.unlock();
     }
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index 6f6b9a6..40f39c0 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -10,8 +10,8 @@
     gwt_xml = "ReviewDB.gwt.xml",
     deps = [
         "//java/com/google/gerrit/extensions:client",
-        "//lib:gwtorm_client",
-        "//lib:gwtorm_client_src",
+        "//lib:gwtorm-client",
+        "//lib:gwtorm-client_src",
     ],
 )
 
diff --git a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
index 765e38c..a70d254 100644
--- a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
@@ -40,7 +40,8 @@
   PRIVATE_BY_DEFAULT("change", "privateByDefault"),
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
-  REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit");
+  REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
 
   // Git config
   private final String section;
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
index 201315e..8d4de05 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/reviewdb/client/Change.java
@@ -253,7 +253,10 @@
     }
   }
 
-  /** Globally unique identification of this change. */
+  /**
+   * Globally unique identification of this change. This generally takes the form of a string
+   * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
+   */
   public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/reviewdb/client/CommentRange.java b/java/com/google/gerrit/reviewdb/client/CommentRange.java
index b9da8d5..3c69538 100644
--- a/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/java/com/google/gerrit/reviewdb/client/CommentRange.java
@@ -33,10 +33,11 @@
   protected CommentRange() {}
 
   public CommentRange(int sl, int sc, int el, int ec) {
-    startLine = sl;
-    startCharacter = sc;
-    endLine = el;
-    endCharacter = ec;
+    // Start position is inclusive; end position is exclusive.
+    startLine = sl; // 1-based
+    startCharacter = sc; // 0-based
+    endLine = el; // 1-based
+    endCharacter = ec; // 0-based
   }
 
   public int getStartLine() {
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index 0f3e4e1..5adf002 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -226,6 +226,6 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(key, value, granted, tag);
+    return Objects.hash(key, value, granted, tag, realAccountId, postSubmit);
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/Project.java b/java/com/google/gerrit/reviewdb/client/Project.java
index 921667e..996f1ec 100644
--- a/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/java/com/google/gerrit/reviewdb/client/Project.java
@@ -99,6 +99,8 @@
 
   protected String themeName;
 
+  protected String configRefState;
+
   protected Project() {}
 
   public Project(Project.NameKey nameKey) {
@@ -239,4 +241,14 @@
   public void setParentName(NameKey n) {
     parent = n;
   }
+
+  /** Returns the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  public String getConfigRefState() {
+    return configRefState;
+  }
+
+  /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  public void setConfigRefState(String state) {
+    configRefState = state;
+  }
 }
diff --git a/java/com/google/gerrit/server/ApprovalCopier.java b/java/com/google/gerrit/server/ApprovalCopier.java
index 922922c..095263e 100644
--- a/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/ApprovalCopier.java
@@ -79,7 +79,6 @@
    *
    * @param db review database.
    * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
    * @param ps new PatchSet
    * @param rw open walk that can read the patch set commit; null to open the repo on demand.
    * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
@@ -88,12 +87,11 @@
   public void copyInReviewDb(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet ps,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig)
       throws OrmException {
-    copyInReviewDb(db, notes, user, ps, rw, repoConfig, Collections.emptyList());
+    copyInReviewDb(db, notes, ps, rw, repoConfig, Collections.emptyList());
   }
 
   /**
@@ -101,7 +99,6 @@
    *
    * @param db review database.
    * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
    * @param ps new PatchSet
    * @param rw open walk that can read the patch set commit; null to open the repo on demand.
    * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
@@ -111,33 +108,30 @@
   public void copyInReviewDb(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet ps,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig,
       Iterable<PatchSetApproval> dontCopy)
       throws OrmException {
     if (PrimaryStorage.of(notes.getChange()) == PrimaryStorage.REVIEW_DB) {
-      db.patchSetApprovals().insert(getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy));
+      db.patchSetApprovals().insert(getForPatchSet(db, notes, ps, rw, repoConfig, dontCopy));
     }
   }
 
   Iterable<PatchSetApproval> getForPatchSet(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet.Id psId,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig)
       throws OrmException {
     return getForPatchSet(
-        db, notes, user, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
+        db, notes, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
   }
 
   Iterable<PatchSetApproval> getForPatchSet(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet.Id psId,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig,
@@ -147,13 +141,12 @@
     if (ps == null) {
       return Collections.emptyList();
     }
-    return getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy);
+    return getForPatchSet(db, notes, ps, rw, repoConfig, dontCopy);
   }
 
   private Iterable<PatchSetApproval> getForPatchSet(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet ps,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig,
@@ -211,7 +204,7 @@
           byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
         }
       }
-      return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
+      return labelNormalizer.normalize(notes, byUser.values()).getNormalized();
     } catch (IOException e) {
       throw new OrmException(e);
     }
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 8365ddb..3625de6 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -263,12 +263,17 @@
 
   private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
     try {
-      return projectCache.checkedGet(notes.getProjectName()).statePermitsRead()
-          && permissionBackend
-              .absentUser(accountId)
-              .change(notes)
-              .database(db)
-              .test(ChangePermission.READ);
+      if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+        return false;
+      }
+      permissionBackend
+          .absentUser(accountId)
+          .change(notes)
+          .database(db)
+          .check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
     } catch (IOException | PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
@@ -388,7 +393,6 @@
   public Iterable<PatchSetApproval> byPatchSet(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet.Id psId,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig)
@@ -396,13 +400,12 @@
     if (!migration.readChanges()) {
       return sortApprovals(db.patchSetApprovals().byPatchSet(psId));
     }
-    return copier.getForPatchSet(db, notes, user, psId, rw, repoConfig);
+    return copier.getForPatchSet(db, notes, psId, rw, repoConfig);
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
       ReviewDb db,
       ChangeNotes notes,
-      CurrentUser user,
       PatchSet.Id psId,
       Account.Id accountId,
       @Nullable RevWalk rw,
@@ -411,7 +414,7 @@
     if (!migration.readChanges()) {
       return sortApprovals(db.patchSetApprovals().byPatchSetUser(psId, accountId));
     }
-    return filterApprovals(byPatchSet(db, notes, user, psId, rw, repoConfig), accountId);
+    return filterApprovals(byPatchSet(db, notes, psId, rw, repoConfig), accountId);
   }
 
   public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes, PatchSet.Id c) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 280a467..e5bc480 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -36,18 +36,47 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
         "//java/org/apache/commons/net",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
+        "//lib:autolink",
         "//lib:automaton",
         "//lib:blame-cache",
-        "//lib:grappa",
+        "//lib:flexmark",
+        "//lib:flexmark-ext-abbreviation",
+        "//lib:flexmark-ext-anchorlink",
+        "//lib:flexmark-ext-autolink",
+        "//lib:flexmark-ext-definition",
+        "//lib:flexmark-ext-emoji",
+        "//lib:flexmark-ext-escaped-character",
+        "//lib:flexmark-ext-footnotes",
+        "//lib:flexmark-ext-gfm-issues",
+        "//lib:flexmark-ext-gfm-strikethrough",
+        "//lib:flexmark-ext-gfm-tables",
+        "//lib:flexmark-ext-gfm-tasklist",
+        "//lib:flexmark-ext-gfm-users",
+        "//lib:flexmark-ext-ins",
+        "//lib:flexmark-ext-jekyll-front-matter",
+        "//lib:flexmark-ext-superscript",
+        "//lib:flexmark-ext-tables",
+        "//lib:flexmark-ext-toc",
+        "//lib:flexmark-ext-typographic",
+        "//lib:flexmark-ext-wikilink",
+        "//lib:flexmark-ext-yaml-front-matter",
+        "//lib:flexmark-formatter",
+        "//lib:flexmark-html-parser",
+        "//lib:flexmark-profile-pegdown",
+        "//lib:flexmark-util",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -56,7 +85,6 @@
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
-        "//lib:pegdown",
         "//lib:protobuf",
         "//lib:servlet-api-3_1",
         "//lib:soy",
diff --git a/java/com/google/gerrit/server/ChangeFinder.java b/java/com/google/gerrit/server/ChangeFinder.java
index 677b091..fd6ae1c 100644
--- a/java/com/google/gerrit/server/ChangeFinder.java
+++ b/java/com/google/gerrit/server/ChangeFinder.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
@@ -235,7 +236,7 @@
     InternalChangeQuery query = queryProvider.get().noFields();
     List<ChangeData> r = query.byLegacyChangeId(id);
     if (r.size() == 1) {
-      changeIdProjectCache.put(id, r.get(0).project().get());
+      changeIdProjectCache.put(id, Url.encode(r.get(0).project().get()));
     }
     return asChangeNotes(r);
   }
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index e635072..a6dbb53 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
@@ -27,7 +28,9 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -129,6 +132,71 @@
   }
 
   /**
+   * Replace an existing change message with the provided new message.
+   *
+   * <p>The ID of a change message is different between NoteDb and ReviewDb. In NoteDb, it's the
+   * commit SHA-1, but in ReviewDb it's generated randomly. To make sure the change message can be
+   * deleted from both NoteDb and ReviewDb, the index of the change message must be used rather than
+   * its ID.
+   *
+   * @param db the {@code ReviewDb} instance to update.
+   * @param update change update.
+   * @param targetMessageIdx the index of the target change message.
+   * @param newMessage the new message which is going to replace the old.
+   * @throws OrmException
+   */
+  public void replaceChangeMessage(
+      ReviewDb db, ChangeUpdate update, int targetMessageIdx, String newMessage)
+      throws OrmException {
+    if (PrimaryStorage.of(update.getChange()).equals(PrimaryStorage.REVIEW_DB)) {
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = unwrapDb(db);
+
+      List<ChangeMessage> messagesInReviewDb =
+          sortChangeMessages(db.changeMessages().byChange(update.getId()));
+      if (migration.readChanges()) {
+        sanityCheckForChangeMessages(messagesInReviewDb, update.getNotes().getChangeMessages());
+      }
+      ChangeMessage targetMessage = messagesInReviewDb.get(targetMessageIdx);
+      targetMessage.setMessage(newMessage);
+      db.changeMessages().upsert(Collections.singleton(targetMessage));
+    }
+
+    update.deleteChangeMessageByRewritingHistory(targetMessageIdx, newMessage);
+  }
+
+  private static void sanityCheckForChangeMessages(
+      List<ChangeMessage> messagesInReviewDb, List<ChangeMessage> messagesInNoteDb) {
+    String message =
+        String.format(
+            "Change messages in ReivewDb and NoteDb don't match: NoteDb %s; ReviewDb %s",
+            messagesInNoteDb, messagesInReviewDb);
+    if (messagesInReviewDb.size() != messagesInNoteDb.size()) {
+      throw new IllegalStateException(message);
+    }
+
+    for (int i = 0; i < messagesInReviewDb.size(); i++) {
+      ChangeMessage messageInReviewDb = messagesInReviewDb.get(i);
+      ChangeMessage messageInNoteDb = messagesInNoteDb.get(i);
+
+      // Don't compare the keys because they are different for the same change message in NoteDb and
+      // ReviewDb.
+      boolean isEqual =
+          Objects.equals(messageInReviewDb.getAuthor(), messageInNoteDb.getAuthor())
+              && Objects.equals(messageInReviewDb.getWrittenOn(), messageInNoteDb.getWrittenOn())
+              && Objects.equals(messageInReviewDb.getMessage(), messageInNoteDb.getMessage())
+              && Objects.equals(messageInReviewDb.getPatchSetId(), messageInNoteDb.getPatchSetId())
+              && Objects.equals(messageInReviewDb.getTag(), messageInNoteDb.getTag())
+              && Objects.equals(messageInReviewDb.getRealAuthor(), messageInNoteDb.getRealAuthor());
+      if (!isEqual) {
+        throw new IllegalStateException(message);
+      }
+    }
+  }
+
+  /**
    * @param tag value of a tag, or null.
    * @return whether the tag starts with the autogenerated prefix.
    */
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index d90f5d0..571f322 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -37,17 +37,9 @@
   private static final Random UUID_RANDOM = new SecureRandom();
   private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
-  private static final int SUBJECT_MAX_LENGTH = 80;
-  private static final String SUBJECT_CROP_APPENDIX = "...";
-  private static final int SUBJECT_CROP_RANGE = 10;
-
   public static final Ordering<PatchSet> PS_ID_ORDER =
       Ordering.from(comparingInt(PatchSet::getPatchSetId));
 
-  public static String formatChangeUrl(String canonicalWebUrl, Change change) {
-    return canonicalWebUrl + "c/" + change.getProject().get() + "/+/" + change.getChangeId();
-  }
-
   /** @return a new unique identifier for change message entities. */
   public static String messageUuid() {
     byte[] buf = new byte[8];
@@ -123,21 +115,6 @@
         id);
   }
 
-  public static String cropSubject(String subject) {
-    if (subject.length() > SUBJECT_MAX_LENGTH) {
-      int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
-      for (int cropPosition = maxLength;
-          cropPosition > maxLength - SUBJECT_CROP_RANGE;
-          cropPosition--) {
-        if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
-          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
-        }
-      }
-      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
-    }
-    return subject;
-  }
-
   public static String status(Change c) {
     return c != null ? c.getStatus().name().toLowerCase() : "deleted";
   }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index c178137..99dfbbb 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -498,11 +498,11 @@
   }
 
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
-    return repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(changeId)).values();
+    return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
   }
 
   private static <T extends Comment> List<T> sort(List<T> comments) {
-    Collections.sort(comments, COMMENT_ORDER);
+    comments.sort(COMMENT_ORDER);
     return comments;
   }
 
diff --git a/java/com/google/gerrit/server/GerritPersonIdent.java b/java/com/google/gerrit/server/GerritPersonIdent.java
index 5d259b3..544a106 100644
--- a/java/com/google/gerrit/server/GerritPersonIdent.java
+++ b/java/com/google/gerrit/server/GerritPersonIdent.java
@@ -20,8 +20,11 @@
 import java.lang.annotation.Retention;
 
 /**
- * Marker on a {@link org.eclipse.jgit.lib.PersonIdent} pointing to the identity representing Gerrit
- * server itself.
+ * Marker on a {@link org.eclipse.jgit.lib.PersonIdent} pointing to the identity + timestamp
+ * representing the Gerrit server itself.
+ *
+ * <p>When injecting this into a singleton class, use {@code Provider<PersonIdent>} so you get a
+ * fresh timestamp for each call to {@code get()}.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
diff --git a/java/com/google/gerrit/server/GerritPersonIdentProvider.java b/java/com/google/gerrit/server/GerritPersonIdentProvider.java
index 87ba55a..3ec07bd 100644
--- a/java/com/google/gerrit/server/GerritPersonIdentProvider.java
+++ b/java/com/google/gerrit/server/GerritPersonIdentProvider.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -30,12 +32,14 @@
 
   @Inject
   public GerritPersonIdentProvider(@GerritServerConfig Config cfg) {
-    String name = cfg.getString("user", null, "name");
-    if (name == null) {
-      name = "Gerrit Code Review";
-    }
-    this.name = name;
-    email = cfg.get(UserConfig.KEY).getCommitterEmail();
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(
+        name, firstNonNull(cfg.getString("user", null, "name"), "Gerrit Code Review"));
+    this.name = name.toString();
+
+    StringBuilder email = new StringBuilder();
+    PersonIdent.appendSanitized(email, cfg.get(UserConfig.KEY).getCommitterEmail());
+    this.email = email.toString();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 16546f9..d9a4cae 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
@@ -54,6 +56,8 @@
 
 /** An authenticated user. */
 public class IdentifiedUser extends CurrentUser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Create an IdentifiedUser, ignoring any per-request state. */
   @Singleton
   public static class GenericFactory {
@@ -375,8 +379,13 @@
     if (effectiveGroups == null) {
       if (authConfig.isIdentityTrustable(state().getExternalIds())) {
         effectiveGroups = groupBackend.membershipsOf(this);
+        logger.atFinest().log(
+            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
       } else {
         effectiveGroups = registeredGroups;
+        logger.atFinest().log(
+            "%s has a non-trusted identity, falling back to %s as known groups",
+            getLoggableName(), lazy(registeredGroups::getKnownGroups));
       }
     }
     return effectiveGroups;
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 252eb61..bf820dd 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -155,7 +155,7 @@
     db.patchSets().update(Collections.singleton(ps));
   }
 
-  private void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
+  private static void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
     Change.Id changeId = update.getChange().getId();
     checkArgument(
         psId.getParentKey().equals(changeId),
@@ -181,17 +181,16 @@
   }
 
   /** Check if the current patch set of the change is locked. */
-  public void checkPatchSetNotLocked(ChangeNotes notes, CurrentUser user)
+  public void checkPatchSetNotLocked(ChangeNotes notes)
       throws OrmException, IOException, ResourceConflictException {
-    if (isPatchSetLocked(notes, user)) {
+    if (isPatchSetLocked(notes)) {
       throw new ResourceConflictException(
           String.format("The current patch set of change %s is locked", notes.getChangeId()));
     }
   }
 
   /** Is the current patch set locked against state changes? */
-  public boolean isPatchSetLocked(ChangeNotes notes, CurrentUser user)
-      throws OrmException, IOException {
+  public boolean isPatchSetLocked(ChangeNotes notes) throws OrmException, IOException {
     Change change = notes.getChange();
     if (change.getStatus() == Change.Status.MERGED) {
       return false;
@@ -202,9 +201,8 @@
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(
-            dbProvider.get(), notes, user, change.currentPatchSetId(), null, null)) {
-      LabelType type = projectState.getLabelTypes(notes, user).byLabel(ap.getLabel());
+        approvalsUtil.byPatchSet(dbProvider.get(), notes, change.currentPatchSetId(), null, null)) {
+      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.getLabel());
       if (type != null
           && ap.getValue() == 1
           && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
@@ -214,14 +212,13 @@
     return false;
   }
 
-  /** Returns the full commit message for the given project at the given patchset revision */
-  public String getFullCommitMessage(Project.NameKey project, PatchSet patchSet)
-      throws IOException {
+  /** Returns the commit for the given project at the given patchset revision */
+  public RevCommit getRevCommit(Project.NameKey project, PatchSet patchSet) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
       rw.parseBody(src);
-      return src.getFullMessage();
+      return src;
     }
   }
 }
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index c16c9c8..caae45e 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 0d8d7c1..fa6cd6c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -166,7 +166,7 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<ReviewDb> dbProvider;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final ChangeIndexer indexer;
   private final Provider<InternalChangeQuery> queryProvider;
 
@@ -176,7 +176,7 @@
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
       Provider<ReviewDb> dbProvider,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
@@ -241,7 +241,7 @@
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
       batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent);
+      batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
       for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
@@ -296,7 +296,11 @@
 
   private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
-    return refDb.getRefs(prefix).keySet();
+    return refDb
+        .getRefsByPrefix(prefix)
+        .stream()
+        .map(r -> r.getName().substring(prefix.length()))
+        .collect(toSet());
   }
 
   public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
@@ -372,6 +376,8 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
+    logger.atFine().log("Read star labels from %s", refName);
+
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
       return StarRef.MISSING;
@@ -444,12 +450,13 @@
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
       throws IOException, OrmException, InvalidLabelsException {
+    logger.atFine().log("Update star labels in %s (labels=%s)", refName, labels);
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
       u.setForceUpdate(true);
       u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent);
+      u.setRefLogIdent(serverIdent.get());
       u.setRefLogMessage("Update star labels", true);
       RefUpdate.Result result = u.update(rw);
       switch (result) {
@@ -476,10 +483,16 @@
 
   private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
       throws IOException, OrmException {
+    if (ObjectId.zeroId().equals(oldObjectId)) {
+      // ref doesn't exist
+      return;
+    }
+
+    logger.atFine().log("Delete star labels in %s", refName);
     RefUpdate u = repo.updateRef(refName);
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(oldObjectId);
-    u.setRefLogIdent(serverIdent);
+    u.setRefLogIdent(serverIdent.get());
     u.setRefLogMessage("Unstar change", true);
     RefUpdate.Result result = u.delete();
     switch (result) {
diff --git a/java/com/google/gerrit/server/UsedAt.java b/java/com/google/gerrit/server/UsedAt.java
new file mode 100644
index 0000000..b564157
--- /dev/null
+++ b/java/com/google/gerrit/server/UsedAt.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 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.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.annotations.GwtCompatible;
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A marker for a method that is public solely because it is called from inside a project or an
+ * organisation using Gerrit.
+ */
+@BindingAnnotation
+@Target({METHOD, TYPE})
+@Retention(RUNTIME)
+@GwtCompatible
+public @interface UsedAt {
+  /** Enumeration of projects that call a method that would otherwise be private. */
+  enum Project {
+    GOOGLE,
+    PLUGIN_DELETE_PROJECT,
+    PLUGIN_SERVICEUSER,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+  }
+
+  /** Reference to the project that uses the method annotated with this annotation. */
+  Project value();
+}
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 76bfcfd..2cde94c 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -154,12 +154,14 @@
   @Override
   public void evict(@Nullable Account.Id accountId) {
     if (accountId != null) {
+      logger.atFine().log("Evict account %d", accountId.get());
       byId.invalidate(accountId);
     }
   }
 
   @Override
   public void evictAll() {
+    logger.atFine().log("Evict all accounts");
     byId.invalidateAll();
   }
 
@@ -179,6 +181,7 @@
 
     @Override
     public Optional<AccountState> load(Account.Id who) throws Exception {
+      logger.atFine().log("Loading account %s", who);
       return accounts.get(who);
     }
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 6aeb691..3bdc71a 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -77,6 +78,7 @@
  */
 public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
   private final Account.Id accountId;
+  private final AllUsersName allUsersName;
   private final Repository repo;
   private final String ref;
 
@@ -87,8 +89,9 @@
   private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
   private List<ValidationError> validationErrors;
 
-  public AccountConfig(Account.Id accountId, Repository allUsersRepo) {
+  public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
     this.accountId = checkNotNull(accountId, "accountId");
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
     this.ref = RefNames.refsUsers(accountId);
   }
@@ -99,7 +102,7 @@
   }
 
   public AccountConfig load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -242,7 +245,7 @@
           new Preferences(
               accountId,
               readConfig(Preferences.PREFERENCES_CONFIG),
-              Preferences.readDefaultConfig(repo),
+              Preferences.readDefaultConfig(allUsersName, repo),
               this);
 
       projectWatches.parse();
@@ -253,7 +256,8 @@
       projectWatches = new ProjectWatches(accountId, new Config(), this);
 
       preferences =
-          new Preferences(accountId, new Config(), Preferences.readDefaultConfig(repo), this);
+          new Preferences(
+              accountId, new Config(), Preferences.readDefaultConfig(allUsersName, repo), this);
     }
 
     Ref externalIdsRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 5c14c94..ee9265f 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.util.Set;
 
 /**
@@ -48,16 +49,5 @@
   }
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
-      throws DirectoryException;
-
-  @SuppressWarnings("serial")
-  public static class DirectoryException extends Exception {
-    public DirectoryException(String message, Throwable why) {
-      super(message, why);
-    }
-
-    public DirectoryException(Throwable why) {
-      super(why);
-    }
-  }
+      throws PermissionBackendException;
 }
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 7be51a0..4398d9e 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -16,13 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Throwables;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
@@ -86,23 +84,18 @@
     provided.add(info);
   }
 
-  public void fill() throws OrmException {
-    try {
-      directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
-    } catch (DirectoryException e) {
-      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      throw new OrmException(e);
-    }
+  public void fill() throws PermissionBackendException {
+    directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
   }
 
-  public void fill(Collection<? extends AccountInfo> infos) throws OrmException {
+  public void fill(Collection<? extends AccountInfo> infos) throws PermissionBackendException {
     for (AccountInfo info : infos) {
       put(info);
     }
     fill();
   }
 
-  public AccountInfo fillOne(Account.Id id) throws OrmException {
+  public AccountInfo fillOne(Account.Id id) throws PermissionBackendException {
     AccountInfo info = get(id);
     fill();
     return info;
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index e56ad72..1854dc1 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -315,4 +316,11 @@
     }
     return properties;
   }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(getAccount().getId());
+    return h.toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 7dff74c..1a61c02 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -134,13 +134,14 @@
   private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     return AccountState.fromAccountConfig(
-        allUsersName, externalIds, new AccountConfig(accountId, allUsersRepository).load());
+        allUsersName,
+        externalIds,
+        new AccountConfig(accountId, allUsersName, allUsersRepository).load());
   }
 
   public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
     return repo.getRefDatabase()
-        .getRefs(RefNames.REFS_USERS)
-        .values()
+        .getRefsByPrefix(RefNames.REFS_USERS)
         .stream()
         .map(r -> Account.Id.fromRef(r.getName()))
         .filter(Objects::nonNull);
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 996e602..fe944ae 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
@@ -49,7 +50,9 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -365,7 +368,7 @@
 
   private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
       throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
     afterReadRevision.run();
     return accountConfig;
   }
@@ -445,11 +448,28 @@
         updatedAccount.getExternalIdNotes());
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-    updatedAccount.getExternalIdNotes().updateCaches();
+
+    // Skip accounts that are updated when evicting the account cache via ExternalIdNotes to avoid
+    // double reindexing. The updated accounts will already be reindexed by ReindexAfterRefUpdate.
+    Set<Account.Id> accountsThatWillBeReindexByReindexAfterRefUpdate =
+        getUpdatedAccounts(batchRefUpdate);
+    updatedAccount
+        .getExternalIdNotes()
+        .updateCaches(accountsThatWillBeReindexByReindexAfterRefUpdate);
+
     gitRefUpdated.fire(
         allUsersName, batchRefUpdate, currentUser != null ? currentUser.state() : null);
   }
 
+  private static Set<Account.Id> getUpdatedAccounts(BatchRefUpdate batchRefUpdate) {
+    return batchRefUpdate
+        .getCommands()
+        .stream()
+        .map(c -> Account.Id.fromRef(c.getRefName()))
+        .filter(Objects::nonNull)
+        .collect(toSet());
+  }
+
   private void commitNewAccountConfig(
       String message,
       Repository allUsersRepo,
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 31e845b..e91ce49 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Streams;
@@ -23,6 +24,9 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.Action;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,11 +38,16 @@
 public class Emails {
   private final ExternalIds externalIds;
   private final Provider<InternalAccountQuery> queryProvider;
+  private final RetryHelper retryHelper;
 
   @Inject
-  public Emails(ExternalIds externalIds, Provider<InternalAccountQuery> queryProvider) {
+  public Emails(
+      ExternalIds externalIds,
+      Provider<InternalAccountQuery> queryProvider,
+      RetryHelper retryHelper) {
     this.externalIds = externalIds;
     this.queryProvider = queryProvider;
+    this.retryHelper = retryHelper;
   }
 
   /**
@@ -63,7 +72,8 @@
   public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException, OrmException {
     return Streams.concat(
             externalIds.byEmail(email).stream().map(ExternalId::accountId),
-            queryProvider.get().byPreferredEmail(email).stream().map(a -> a.getAccount().getId()))
+            executeIndexQuery(() -> queryProvider.get().byPreferredEmail(email).stream())
+                .map(a -> a.getAccount().getId()))
         .collect(toImmutableSet());
   }
 
@@ -80,12 +90,28 @@
         .entries()
         .stream()
         .forEach(e -> builder.put(e.getKey(), e.getValue().accountId()));
-    queryProvider
-        .get()
-        .byPreferredEmail(emails)
-        .entries()
-        .stream()
+    executeIndexQuery(() -> queryProvider.get().byPreferredEmail(emails).entries().stream())
         .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().getId()));
     return builder.build();
   }
+
+  /**
+   * Returns the accounts with the given email.
+   *
+   * <p>This method behaves just like {@link #getAccountFor(String)}, except that accounts are not
+   * looked up by their preferred email. Thus, this method does not rely on the accounts index.
+   */
+  public ImmutableSet<Account.Id> getAccountForExternal(String email) throws IOException {
+    return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
+  }
+
+  private <T> T executeIndexQuery(Action<T> action) throws OrmException {
+    try {
+      return retryHelper.execute(ActionType.INDEX_QUERY, action, OrmException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, OrmException.class);
+      throw new OrmException(e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
index 803d491..1b15512 100644
--- a/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/java/com/google/gerrit/server/account/GroupBackends.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -25,12 +27,7 @@
 public class GroupBackends {
 
   public static final Comparator<GroupReference> GROUP_REF_NAME_COMPARATOR =
-      new Comparator<GroupReference>() {
-        @Override
-        public int compare(GroupReference a, GroupReference b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
+      comparing(GroupReference::getName);
 
   /**
    * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e7aae15..1f8cb88 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -116,6 +116,7 @@
   @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
+      logger.atFine().log("Evict group %s by ID", groupId.get());
       byId.invalidate(groupId);
     }
   }
@@ -123,6 +124,7 @@
   @Override
   public void evict(AccountGroup.NameKey groupName) {
     if (groupName != null) {
+      logger.atFine().log("Evict group '%s' by name", groupName.get());
       byName.invalidate(groupName.get());
     }
   }
@@ -130,6 +132,7 @@
   @Override
   public void evict(AccountGroup.UUID groupUuid) {
     if (groupUuid != null) {
+      logger.atFine().log("Evict group %s by UUID", groupUuid.get());
       byUUID.invalidate(groupUuid.get());
     }
   }
@@ -144,6 +147,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
+      logger.atFine().log("Loading group %s by ID", key);
       return groupQueryProvider.get().byId(key);
     }
   }
@@ -158,6 +162,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
+      logger.atFine().log("Loading group '%s' by name", name);
       return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
     }
   }
@@ -172,6 +177,7 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
+      logger.atFine().log("Loading group %s by UUID", uuid);
       return groups.getGroup(new AccountGroup.UUID(uuid));
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f262a79..5fb4ca1 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -112,6 +112,7 @@
   @Override
   public void evictGroupsWithMember(Account.Id memberId) {
     if (memberId != null) {
+      logger.atFine().log("Evict groups with member %d", memberId.get());
       groupsWithMember.invalidate(memberId);
     }
   }
@@ -119,9 +120,11 @@
   @Override
   public void evictParentGroupsOf(AccountGroup.UUID groupId) {
     if (groupId != null) {
+      logger.atFine().log("Evict parent groups of %s", groupId.get());
       parentGroups.invalidate(groupId);
 
       if (!AccountGroup.isInternalGroup(groupId)) {
+        logger.atFine().log("Evict external group %s", groupId.get());
         external.invalidate(EXTERNAL_NAME);
       }
     }
@@ -148,6 +151,7 @@
 
     @Override
     public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) throws OrmException {
+      logger.atFine().log("Loading groups with member %s", memberId);
       return groupQueryProvider
           .get()
           .byMember(memberId)
@@ -168,6 +172,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+      logger.atFine().log("Loading parent groups of %s", key);
       return groupQueryProvider
           .get()
           .bySubgroup(key)
@@ -187,6 +192,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
+      logger.atFine().log("Loading all external groups");
       return groups.getExternalGroups().collect(toImmutableList());
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index aa16020..faa1621 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -29,12 +29,14 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
 
+@Singleton
 public class GroupMembers {
 
   private final GroupCache groupCache;
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 8c2bc10..ce97ff9 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -18,16 +18,23 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -35,6 +42,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 @Singleton
@@ -51,23 +59,45 @@
   private final AccountCache accountCache;
   private final DynamicItem<AvatarProvider> avatar;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   InternalAccountDirectory(
       AccountCache accountCache,
       DynamicItem<AvatarProvider> avatar,
-      IdentifiedUser.GenericFactory userFactory) {
+      IdentifiedUser.GenericFactory userFactory,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
     this.accountCache = accountCache;
     this.avatar = avatar;
     this.userFactory = userFactory;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
-      throws DirectoryException {
+      throws PermissionBackendException {
     if (options.equals(ID_ONLY)) {
       return;
     }
+
+    boolean canModifyAccount = false;
+    Account.Id currentUserId = null;
+    if (self.get().isIdentifiedUser()) {
+      currentUserId = self.get().getAccountId();
+
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+        canModifyAccount = true;
+      } catch (AuthException e) {
+        canModifyAccount = false;
+      }
+    }
+
+    Set<FillOptions> fillOptionsWithoutSecondaryEmails =
+        Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
     Set<Account.Id> ids =
         Streams.stream(in).map(a -> new Account.Id(a._accountId)).collect(toSet());
     Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
@@ -75,7 +105,15 @@
       Account.Id id = new Account.Id(info._accountId);
       AccountState state = accountStates.get(id);
       if (state != null) {
-        fill(info, accountStates.get(id), options);
+        if (!options.contains(FillOptions.SECONDARY_EMAILS)
+            || Objects.equals(currentUserId, state.getAccount().getId())
+            || canModifyAccount) {
+          fill(info, accountStates.get(id), options);
+        } else {
+          // user is not allowed to see secondary emails
+          fill(info, accountStates.get(id), fillOptionsWithoutSecondaryEmails);
+        }
+
       } else {
         info._accountId = options.contains(FillOptions.ID) ? id.get() : null;
       }
diff --git a/java/com/google/gerrit/server/account/Preferences.java b/java/com/google/gerrit/server/account/Preferences.java
index aa09675..5bf6e26 100644
--- a/java/com/google/gerrit/server/account/Preferences.java
+++ b/java/com/google/gerrit/server/account/Preferences.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -414,25 +415,28 @@
     return urlAliases;
   }
 
-  public static GeneralPreferencesInfo readDefaultGeneralPreferences(Repository allUsersRepo)
+  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseGeneralPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  public static DiffPreferencesInfo readDefaultDiffPreferences(Repository allUsersRepo)
+  public static DiffPreferencesInfo readDefaultDiffPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseDiffPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  public static EditPreferencesInfo readDefaultEditPreferences(Repository allUsersRepo)
+  public static EditPreferencesInfo readDefaultEditPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseEditPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  static Config readDefaultConfig(Repository allUsersRepo)
+  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(allUsersRepo);
+    defaultPrefs.load(allUsersName, allUsersRepo);
     return defaultPrefs.getConfig();
   }
 
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 04a3a95..965f1ba 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -121,7 +121,7 @@
         throws IOException, ConfigInvalidException {
       try (Repository git = repoManager.openRepository(allUsersName)) {
         VersionedAuthorizedKeys authorizedKeys = authorizedKeysFactory.create(accountId);
-        authorizedKeys.load(git);
+        authorizedKeys.load(allUsersName, git);
         return authorizedKeys;
       }
     }
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index 15f7a0ad4..bb1ade7 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -14,37 +14,99 @@
 
 package com.google.gerrit.server.account.externalids;
 
+import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
+import static java.util.stream.Collectors.toList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multimaps;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import java.util.Collection;
 
-/**
- * Cache value containing all external IDs.
- *
- * <p>All returned fields are unmodifiable.
- */
+/** Cache value containing all external IDs. */
 @AutoValue
 public abstract class AllExternalIds {
-  static AllExternalIds create(Multimap<Id, ExternalId> byAccount) {
-    SetMultimap<String, ExternalId> byEmailCopy =
-        MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(1).build();
-    byAccount
-        .values()
-        .stream()
-        .filter(e -> !Strings.isNullOrEmpty(e.email()))
-        .forEach(e -> byEmailCopy.put(e.email(), e));
-
+  static AllExternalIds create(SetMultimap<Account.Id, ExternalId> byAccount) {
     return new AutoValue_AllExternalIds(
-        Multimaps.unmodifiableSetMultimap(
-            MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(5).build(byAccount)),
-        byEmailCopy);
+        ImmutableSetMultimap.copyOf(byAccount), byEmailCopy(byAccount.values()));
   }
 
-  public abstract SetMultimap<Id, ExternalId> byAccount();
+  static AllExternalIds create(Collection<ExternalId> externalIds) {
+    return new AutoValue_AllExternalIds(
+        externalIds.stream().collect(toImmutableSetMultimap(e -> e.accountId(), e -> e)),
+        byEmailCopy(externalIds));
+  }
 
-  public abstract SetMultimap<String, ExternalId> byEmail();
+  private static ImmutableSetMultimap<String, ExternalId> byEmailCopy(
+      Collection<ExternalId> externalIds) {
+    return externalIds
+        .stream()
+        .filter(e -> !Strings.isNullOrEmpty(e.email()))
+        .collect(toImmutableSetMultimap(e -> e.email(), e -> e));
+  }
+
+  public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
+
+  public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
+
+  enum Serializer implements CacheSerializer<AllExternalIds> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(AllExternalIds object) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      AllExternalIdsProto.Builder allBuilder = AllExternalIdsProto.newBuilder();
+      object
+          .byAccount()
+          .values()
+          .stream()
+          .map(extId -> toProto(idConverter, extId))
+          .forEach(allBuilder::addExternalId);
+      return ProtoCacheSerializers.toByteArray(allBuilder.build());
+    }
+
+    private static ExternalIdProto toProto(ObjectIdConverter idConverter, ExternalId externalId) {
+      ExternalIdProto.Builder b =
+          ExternalIdProto.newBuilder()
+              .setKey(externalId.key().get())
+              .setAccountId(externalId.accountId().get());
+      if (externalId.email() != null) {
+        b.setEmail(externalId.email());
+      }
+      if (externalId.password() != null) {
+        b.setPassword(externalId.password());
+      }
+      if (externalId.blobId() != null) {
+        b.setBlobId(idConverter.toByteString(externalId.blobId()));
+      }
+      return b.build();
+    }
+
+    @Override
+    public AllExternalIds deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return create(
+          ProtoCacheSerializers.parseUnchecked(AllExternalIdsProto.parser(), in)
+              .getExternalIdList()
+              .stream()
+              .map(proto -> toExternalId(idConverter, proto))
+              .collect(toList()));
+    }
+
+    private static ExternalId toExternalId(ObjectIdConverter idConverter, ExternalIdProto proto) {
+      return ExternalId.create(
+          ExternalId.Key.parse(proto.getKey()),
+          new Account.Id(proto.getAccountId()),
+          // ExternalId treats null and empty strings the same, so no need to distinguish here.
+          proto.getEmail(),
+          proto.getPassword(),
+          !proto.getBlobId().isEmpty() ? idConverter.fromByteString(proto.getBlobId()) : null);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index db8ea41..96ea0cc 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -47,14 +47,12 @@
   // corresponding regular expressions in the
   // com.google.gerrit.client.account.UsernameField class.
   private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
-  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9._@-]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
   private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
 
   /** Regular expression that a username must match. */
   private static final String USER_NAME_PATTERN_REGEX =
-      "^"
-          + //
-          "("
+      "^("
           + //
           USER_NAME_PATTERN_FIRST_REGEX
           + //
@@ -67,9 +65,7 @@
           + //
           USER_NAME_PATTERN_FIRST_REGEX
           + //
-          ")"
-          + //
-          "$";
+          ")$";
 
   private static final Pattern USER_NAME_PATTERN = Pattern.compile(USER_NAME_PATTERN_REGEX);
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index a8844cd..1ac737e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -34,8 +34,7 @@
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException;
+      Collection<ExternalId> toAdd);
 
   Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 533b1c0..5f64568 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -16,9 +16,8 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
@@ -60,8 +59,7 @@
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException {
+      Collection<ExternalId> toAdd) {
     updateCache(
         oldNotesRev,
         newNotesRev,
@@ -121,17 +119,17 @@
   private void updateCache(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
-      Consumer<Multimap<Account.Id, ExternalId>> update) {
+      Consumer<SetMultimap<Account.Id, ExternalId>> update) {
     lock.lock();
     try {
-      ListMultimap<Account.Id, ExternalId> m;
+      SetMultimap<Account.Id, ExternalId> m;
       if (!ObjectId.zeroId().equals(oldNotesRev)) {
         m =
             MultimapBuilder.hashKeys()
-                .arrayListValues()
+                .hashSetValues()
                 .build(extIdsByAccount.get(oldNotesRev).byAccount());
       } else {
-        m = MultimapBuilder.hashKeys().arrayListValues().build();
+        m = MultimapBuilder.hashKeys().hashSetValues().build();
       }
       update.accept(m);
       extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
@@ -152,13 +150,10 @@
 
     @Override
     public AllExternalIds load(ObjectId notesRev) throws Exception {
-      Multimap<Account.Id, ExternalId> extIdsByAccount =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      for (ExternalId extId : externalIdReader.all(notesRev)) {
-        extId.checkThatBlobIdIsSet();
-        extIdsByAccount.put(extId.accountId(), extId);
-      }
-      return AllExternalIds.create(extIdsByAccount);
+      logger.atFine().log("Loading external IDs (revision=%s)", notesRev);
+      ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
+      externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
+      return AllExternalIds.create(externalIds);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index 228b1e6..fc311e7 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.server.account.externalids.ExternalIdCacheImpl.Loader;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
 import org.eclipse.jgit.lib.ObjectId;
@@ -23,7 +24,7 @@
 public class ExternalIdModule extends CacheModule {
   @Override
   protected void configure() {
-    cache(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
+    persist(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
         // The cached data is potentially pretty large and we are always only interested
         // in the latest value. However, due to a race condition, it is possible for different
         // threads to observe different values of the meta ref, and hence request different keys
@@ -32,7 +33,11 @@
         // memory.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(Loader.class);
+        .loader(Loader.class)
+        .diskLimit(-1)
+        .version(1)
+        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
+        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
 
     bind(ExternalIdCacheImpl.class);
     bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 8057dd8..b117888 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -117,24 +118,32 @@
     private final AccountCache accountCache;
     private final Provider<AccountIndexer> accountIndexer;
     private final MetricMaker metricMaker;
+    private final AllUsersName allUsersName;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
         AccountCache accountCache,
         Provider<AccountIndexer> accountIndexer,
-        MetricMaker metricMaker) {
+        MetricMaker metricMaker,
+        AllUsersName allUsersName) {
       this.externalIdCache = externalIdCache;
       this.accountCache = accountCache;
       this.accountIndexer = accountIndexer;
       this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
+              externalIdCache,
+              accountCache,
+              accountIndexer,
+              metricMaker,
+              allUsersName,
+              allUsersRepo)
           .load();
     }
 
@@ -142,7 +151,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
+              externalIdCache,
+              accountCache,
+              accountIndexer,
+              metricMaker,
+              allUsersName,
+              allUsersRepo)
           .load(rev);
     }
   }
@@ -151,23 +165,30 @@
   public static class FactoryNoReindex implements ExternalIdNotesLoader {
     private final ExternalIdCache externalIdCache;
     private final MetricMaker metricMaker;
+    private final AllUsersName allUsersName;
 
     @Inject
-    FactoryNoReindex(ExternalIdCache externalIdCache, MetricMaker metricMaker) {
+    FactoryNoReindex(
+        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load();
+      return new ExternalIdNotes(
+              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load(rev);
+      return new ExternalIdNotes(
+              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+          .load(rev);
     }
   }
 
@@ -177,10 +198,15 @@
    *
    * @return read-only {@link ExternalIdNotes} instance
    */
-  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo)
+  public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .setReadOnly()
         .load();
   }
@@ -195,10 +221,16 @@
    *     external IDs will be empty
    * @return read-only {@link ExternalIdNotes} instance
    */
-  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo, @Nullable ObjectId rev)
+  public static ExternalIdNotes loadReadOnly(
+      AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .setReadOnly()
         .load(rev);
   }
@@ -213,16 +245,23 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(Repository allUsersRepo)
+  public static ExternalIdNotes loadNoCacheUpdate(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .load();
   }
 
   private final ExternalIdCache externalIdCache;
   @Nullable private final AccountCache accountCache;
   @Nullable private final Provider<AccountIndexer> accountIndexer;
+  private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
 
@@ -243,6 +282,7 @@
       @Nullable AccountCache accountCache,
       @Nullable Provider<AccountIndexer> accountIndexer,
       MetricMaker metricMaker,
+      AllUsersName allUsersName,
       Repository allUsersRepo) {
     this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.accountCache = accountCache;
@@ -251,6 +291,7 @@
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
+    this.allUsersName = checkNotNull(allUsersName, "allUsersRepo");
     this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
   }
 
@@ -279,7 +320,7 @@
    * @return {@link ExternalIdNotes} instance for chaining
    */
   private ExternalIdNotes load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -298,10 +339,10 @@
       return load();
     }
     if (ObjectId.zeroId().equals(rev)) {
-      load(repo, null);
+      load(allUsersName, repo, null);
       return this;
     }
-    load(repo, rev);
+    load(allUsersName, repo, rev);
     return this;
   }
 
@@ -615,6 +656,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    logger.atFine().log("Reading external IDs");
+
     noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
 
     if (afterReadRevision != null) {
@@ -636,12 +679,29 @@
    *
    * <p>Must only be called after committing changes.
    *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
    *
    * <p>No eviction from account cache and no reindex if this instance was created by {@link
    * FactoryNoReindex}.
    */
   public void updateCaches() throws IOException {
+    updateCaches(ImmutableSet.of());
+  }
+
+  /**
+   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+   * external IDs were modified.
+   *
+   * <p>Must only be called after committing changes.
+   *
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
+   *
+   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
+   *
+   * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
+   *     this case the caller must take care to evict them otherwise
+   */
+  public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
     checkState(oldRev != null, "no changes committed yet");
 
     ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
@@ -661,6 +721,7 @@
                   externalIdCacheUpdates.getAdded().stream(),
                   externalIdCacheUpdates.getRemoved().stream())
               .map(ExternalId::accountId)
+              .filter(i -> !accountsToSkip.contains(i))
               .collect(toSet())) {
         if (accountCache != null) {
           accountCache.evict(id);
@@ -683,6 +744,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating external IDs");
+
     if (Strings.isNullOrEmpty(commit.getMessage())) {
       commit.setMessage("Update external IDs\n");
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index ee6d5cd..cf5500e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -99,7 +99,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo).all();
     }
   }
 
@@ -118,7 +118,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo, rev).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).all();
     }
   }
 
@@ -127,7 +127,7 @@
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
     }
   }
 
@@ -137,7 +137,7 @@
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo, rev).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 0756a72..14ead2f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -59,14 +59,14 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(repo));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo));
     }
   }
 
   public List<ConsistencyProblemInfo> check(ObjectId rev)
       throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(repo, rev));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev));
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 12c65ca..15e21fe 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -19,6 +19,9 @@
 
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.accounts.AgreementInput;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
@@ -29,10 +32,10 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.AgreementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -50,6 +53,7 @@
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
 import com.google.gerrit.server.restapi.account.DeleteActive;
+import com.google.gerrit.server.restapi.account.DeleteDraftComments;
 import com.google.gerrit.server.restapi.account.DeleteEmail;
 import com.google.gerrit.server.restapi.account.DeleteExternalIds;
 import com.google.gerrit.server.restapi.account.DeleteSshKey;
@@ -57,6 +61,7 @@
 import com.google.gerrit.server.restapi.account.GetActive;
 import com.google.gerrit.server.restapi.account.GetAgreements;
 import com.google.gerrit.server.restapi.account.GetAvatar;
+import com.google.gerrit.server.restapi.account.GetDetail;
 import com.google.gerrit.server.restapi.account.GetDiffPreferences;
 import com.google.gerrit.server.restapi.account.GetEditPreferences;
 import com.google.gerrit.server.restapi.account.GetEmails;
@@ -77,7 +82,6 @@
 import com.google.gerrit.server.restapi.account.StarredChanges;
 import com.google.gerrit.server.restapi.account.Stars;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
@@ -92,6 +96,7 @@
   private final AccountResource account;
   private final ChangesCollection changes;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final GetDetail getDetail;
   private final GetAvatar getAvatar;
   private final GetPreferences getPreferences;
   private final SetPreferences setPreferences;
@@ -108,7 +113,7 @@
   private final Stars.Get starsGet;
   private final Stars.Post starsPost;
   private final GetEmails getEmails;
-  private final CreateEmail.Factory createEmailFactory;
+  private final CreateEmail createEmail;
   private final DeleteEmail deleteEmail;
   private final GpgApiAdapter gpgApiAdapter;
   private final GetSshKeys getSshKeys;
@@ -123,6 +128,7 @@
   private final Index index;
   private final GetExternalIds getExternalIds;
   private final DeleteExternalIds deleteExternalIds;
+  private final DeleteDraftComments deleteDraftComments;
   private final PutStatus putStatus;
   private final GetGroups getGroups;
   private final EmailApiImpl.Factory emailApi;
@@ -131,6 +137,7 @@
   AccountApiImpl(
       AccountLoader.Factory ailf,
       ChangesCollection changes,
+      GetDetail getDetail,
       GetAvatar getAvatar,
       GetPreferences getPreferences,
       SetPreferences setPreferences,
@@ -147,7 +154,7 @@
       Stars.Get starsGet,
       Stars.Post starsPost,
       GetEmails getEmails,
-      CreateEmail.Factory createEmailFactory,
+      CreateEmail createEmail,
       DeleteEmail deleteEmail,
       GpgApiAdapter gpgApiAdapter,
       GetSshKeys getSshKeys,
@@ -162,6 +169,7 @@
       Index index,
       GetExternalIds getExternalIds,
       DeleteExternalIds deleteExternalIds,
+      DeleteDraftComments deleteDraftComments,
       PutStatus putStatus,
       GetGroups getGroups,
       EmailApiImpl.Factory emailApi,
@@ -169,6 +177,7 @@
     this.account = account;
     this.accountLoaderFactory = ailf;
     this.changes = changes;
+    this.getDetail = getDetail;
     this.getAvatar = getAvatar;
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
@@ -185,7 +194,7 @@
     this.starsGet = starsGet;
     this.starsPost = starsPost;
     this.getEmails = getEmails;
-    this.createEmailFactory = createEmailFactory;
+    this.createEmail = createEmail;
     this.deleteEmail = deleteEmail;
     this.getSshKeys = getSshKeys;
     this.addSshKey = addSshKey;
@@ -200,6 +209,7 @@
     this.index = index;
     this.getExternalIds = getExternalIds;
     this.deleteExternalIds = deleteExternalIds;
+    this.deleteDraftComments = deleteDraftComments;
     this.putStatus = putStatus;
     this.getGroups = getGroups;
     this.emailApi = emailApi;
@@ -218,6 +228,15 @@
   }
 
   @Override
+  public AccountDetailInfo detail() throws RestApiException {
+    try {
+      return getDetail.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get detail", e);
+    }
+  }
+
+  @Override
   public boolean getActive() throws RestApiException {
     Response<String> result = getActive.apply(account);
     return result.statusCode() == SC_OK && result.value().equals("ok");
@@ -327,9 +346,8 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
-      starredChangesCreate.setChange(rsrc);
-      starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(
+          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -380,7 +398,7 @@
   public List<GroupInfo> getGroups() throws RestApiException {
     try {
       return getGroups.apply(account);
-    } catch (OrmException e) {
+    } catch (Exception e) {
       throw asRestApiException("Cannot get groups", e);
     }
   }
@@ -398,7 +416,7 @@
   public void addEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmailFactory.create(input.email).apply(rsrc, input);
+      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot add email", e);
     }
@@ -418,7 +436,7 @@
   public EmailApi createEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmailFactory.create(input.email).apply(rsrc, input);
+      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
       return email(rsrc.getEmail());
     } catch (Exception e) {
       throw asRestApiException("Cannot create email", e);
@@ -505,7 +523,11 @@
 
   @Override
   public List<AgreementInfo> listAgreements() throws RestApiException {
-    return getAgreements.apply(account);
+    try {
+      return getAgreements.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get agreements", e);
+    }
   }
 
   @Override
@@ -545,4 +567,14 @@
       throw asRestApiException("Cannot delete external IDs", e);
     }
   }
+
+  @Override
+  public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
+      throws RestApiException {
+    try {
+      return deleteDraftComments.apply(account, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft comments", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 44b6610..ca5aca4 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -45,7 +45,7 @@
   private final AccountApiImpl.Factory api;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
-  private final CreateAccount.Factory createAccount;
+  private final CreateAccount createAccount;
   private final Provider<QueryAccounts> queryAccountsProvider;
 
   @Inject
@@ -54,7 +54,7 @@
       AccountApiImpl.Factory api,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
-      CreateAccount.Factory createAccount,
+      CreateAccount createAccount,
       Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
@@ -99,9 +99,13 @@
       throw new BadRequestException("AccountInput must specify username");
     }
     try {
-      CreateAccount impl = createAccount.create(in.username);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createAccount.getClass()));
+      AccountInfo info =
+          createAccount
+              .apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.username), in)
+              .value();
       return id(info._accountId);
     } catch (Exception e) {
       throw asRestApiException("Cannot create account " + in.username, e);
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index fb42ed0..358a3a8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -70,6 +69,7 @@
 import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
+import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
 import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
@@ -153,7 +153,7 @@
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
-  private final PureRevert pureRevert;
+  private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
 
   @Inject
@@ -200,7 +200,7 @@
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
-      PureRevert pureRevert,
+      Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
@@ -245,7 +245,7 @@
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
-    this.pureRevert = pureRevert;
+    this.getPureRevertProvider = getPureRevertProvider;
     this.stars = stars;
     this.change = change;
   }
@@ -714,7 +714,9 @@
   @Override
   public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
     try {
-      return pureRevert.get(change.getNotes(), claimedOriginal);
+      GetPureRevert getPureRevert = getPureRevertProvider.get();
+      getPureRevert.setClaimedOriginal(claimedOriginal);
+      return getPureRevert.apply(change);
     } catch (Exception e) {
       throw asRestApiException("Cannot compute pure revert", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 92aae03..73f6740 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -47,8 +47,8 @@
   private final ChangeEdits.Detail editDetail;
   private final ChangeEdits.Post changeEditsPost;
   private final DeleteChangeEdit deleteChangeEdit;
-  private final RebaseChangeEdit.Rebase rebaseChangeEdit;
-  private final PublishChangeEdit.Publish publishChangeEdit;
+  private final RebaseChangeEdit rebaseChangeEdit;
+  private final PublishChangeEdit publishChangeEdit;
   private final ChangeEdits.Get changeEditsGet;
   private final ChangeEdits.Put changeEditsPut;
   private final ChangeEdits.DeleteContent changeEditDeleteContent;
@@ -62,8 +62,8 @@
       ChangeEdits.Detail editDetail,
       ChangeEdits.Post changeEditsPost,
       DeleteChangeEdit deleteChangeEdit,
-      RebaseChangeEdit.Rebase rebaseChangeEdit,
-      PublishChangeEdit.Publish publishChangeEdit,
+      RebaseChangeEdit rebaseChangeEdit,
+      PublishChangeEdit publishChangeEdit,
       ChangeEdits.Get changeEditsGet,
       ChangeEdits.Put changeEditsPut,
       ChangeEdits.DeleteContent changeEditDeleteContent,
diff --git a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
index 988ac91..14310e8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
@@ -17,9 +17,11 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.ChangeMessageResource;
+import com.google.gerrit.server.restapi.change.DeleteChangeMessage;
 import com.google.gerrit.server.restapi.change.GetChangeMessage;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -30,12 +32,16 @@
   }
 
   private final GetChangeMessage getChangeMessage;
+  private final DeleteChangeMessage deleteChangeMessage;
   private final ChangeMessageResource changeMessageResource;
 
   @Inject
   ChangeMessageApiImpl(
-      GetChangeMessage getChangeMessage, @Assisted ChangeMessageResource changeMessageResource) {
+      GetChangeMessage getChangeMessage,
+      DeleteChangeMessage deleteChangeMessage,
+      @Assisted ChangeMessageResource changeMessageResource) {
     this.getChangeMessage = getChangeMessage;
+    this.deleteChangeMessage = deleteChangeMessage;
     this.changeMessageResource = changeMessageResource;
   }
 
@@ -47,4 +53,13 @@
       throw asRestApiException("Cannot retrieve change message", e);
     }
   }
+
+  @Override
+  public ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException {
+    try {
+      return deleteChangeMessage.apply(changeMessageResource, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change message", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 247be44..bac6649 100644
--- a/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -49,7 +49,7 @@
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
   private final PermissionBackend permissionBackend;
-  private final CreateGroup.Factory createGroup;
+  private final CreateGroup createGroup;
   private final GroupApiImpl.Factory api;
 
   @Inject
@@ -60,7 +60,7 @@
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       PermissionBackend permissionBackend,
-      CreateGroup.Factory createGroup,
+      CreateGroup createGroup,
       GroupApiImpl.Factory api) {
     this.accounts = accounts;
     this.groups = groups;
@@ -90,9 +90,11 @@
       throw new BadRequestException("GroupInput must specify name");
     }
     try {
-      CreateGroup impl = createGroup.create(in.name);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createGroup.getClass()));
+      GroupInfo info =
+          createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(in.name), in);
       return id(info.id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create group " + in.name, e);
diff --git a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
index fb2fb27..e570655 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -65,6 +65,23 @@
   }
 
   @Override
+  @Deprecated
+  public PluginApi install(
+      String name, com.google.gerrit.extensions.common.InstallPluginInput input)
+      throws RestApiException {
+    return install(name, convertInput(input));
+  }
+
+  @SuppressWarnings("deprecation")
+  private InstallPluginInput convertInput(
+      com.google.gerrit.extensions.common.InstallPluginInput input) {
+    InstallPluginInput result = new InstallPluginInput();
+    result.url = input.url;
+    result.raw = input.raw;
+    return result;
+  }
+
+  @Override
   public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
     try {
       Response<PluginInfo> created =
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 78b34b8..b3506fc 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -46,7 +46,7 @@
   }
 
   private final BranchesCollection branches;
-  private final CreateBranch.Factory createBranchFactory;
+  private final CreateBranch createBranch;
   private final DeleteBranch deleteBranch;
   private final FilesCollection filesCollection;
   private final GetBranch getBranch;
@@ -58,7 +58,7 @@
   @Inject
   BranchApiImpl(
       BranchesCollection branches,
-      CreateBranch.Factory createBranchFactory,
+      CreateBranch createBranch,
       DeleteBranch deleteBranch,
       FilesCollection filesCollection,
       GetBranch getBranch,
@@ -67,7 +67,7 @@
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
-    this.createBranchFactory = createBranchFactory;
+    this.createBranch = createBranch;
     this.deleteBranch = deleteBranch;
     this.filesCollection = filesCollection;
     this.getBranch = getBranch;
@@ -80,7 +80,7 @@
   @Override
   public BranchApi create(BranchInput input) throws RestApiException {
     try {
-      createBranchFactory.create(ref).apply(project, input);
+      createBranch.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create branch", e);
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
index 07924fa..c44f5bb 100644
--- a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.DashboardApi;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 46d9180..463c23e 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -34,6 +36,7 @@
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.HeadInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -52,6 +55,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.Check;
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -64,6 +68,7 @@
 import com.google.gerrit.server.restapi.project.GetDescription;
 import com.google.gerrit.server.restapi.project.GetHead;
 import com.google.gerrit.server.restapi.project.GetParent;
+import com.google.gerrit.server.restapi.project.Index;
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListChildProjects;
 import com.google.gerrit.server.restapi.project.ListDashboards;
@@ -88,7 +93,7 @@
   }
 
   private final PermissionBackend permissionBackend;
-  private final CreateProject.Factory createProjectFactory;
+  private final CreateProject createProject;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
   private final GetDescription getDescription;
@@ -113,16 +118,18 @@
   private final CommitApiImpl.Factory commitApi;
   private final DashboardApiImpl.Factory dashboardApi;
   private final CheckAccess checkAccess;
+  private final Check check;
   private final Provider<ListDashboards> listDashboards;
   private final GetHead getHead;
   private final SetHead setHead;
   private final GetParent getParent;
   private final SetParent setParent;
+  private final Index index;
 
   @AssistedInject
   ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -145,15 +152,17 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
-        createProjectFactory,
+        createProject,
         projectApi,
         projects,
         getDescription,
@@ -177,18 +186,20 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
         getParent,
         setParent,
+        index,
         null);
   }
 
   @AssistedInject
   ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -211,15 +222,17 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted String name) {
     this(
         permissionBackend,
-        createProjectFactory,
+        createProject,
         projectApi,
         projects,
         getDescription,
@@ -243,17 +256,19 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
         getParent,
         setParent,
+        index,
         name);
   }
 
   private ProjectApiImpl(
       PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
+      CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -277,14 +292,16 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       String name) {
     this.permissionBackend = permissionBackend;
-    this.createProjectFactory = createProjectFactory;
+    this.createProject = createProject;
     this.projectApi = projectApi;
     this.projects = projects;
     this.getDescription = getDescription;
@@ -308,12 +325,14 @@
     this.createAccessChange = createAccessChange;
     this.dashboardApi = dashboardApi;
     this.checkAccess = checkAccess;
+    this.check = check;
     this.listDashboards = listDashboards;
     this.getHead = getHead;
     this.setHead = setHead;
     this.getParent = getParent;
     this.setParent = setParent;
     this.name = name;
+    this.index = index;
   }
 
   @Override
@@ -330,9 +349,10 @@
       if (in.name != null && !name.equals(in.name)) {
         throw new BadRequestException("name must match input.name");
       }
-      CreateProject impl = createProjectFactory.create(name);
-      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      impl.apply(TopLevelResource.INSTANCE, in);
+      permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(createProject.getClass()));
+      createProject.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(name), in);
       return projectApi.create(projects.parse(name));
     } catch (Exception e) {
       throw asRestApiException("Cannot create project: " + e.getMessage(), e);
@@ -362,15 +382,6 @@
   }
 
   @Override
-  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
-    try {
-      return checkAccess.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check access rights", e);
-    }
-  }
-
-  @Override
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
@@ -389,6 +400,24 @@
   }
 
   @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
+    }
+  }
+
+  @Override
+  public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+    try {
+      return check.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check project", e);
+    }
+  }
+
+  @Override
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
@@ -594,6 +623,17 @@
     }
   }
 
+  @Override
+  public void index(boolean indexChildren) throws RestApiException {
+    try {
+      IndexProjectInput input = new IndexProjectInput();
+      input.indexChildren = indexChildren;
+      index.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index project", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 03e2162..005486a 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -39,7 +39,7 @@
   }
 
   private final ListTags listTags;
-  private final CreateTag.Factory createTagFactory;
+  private final CreateTag createTag;
   private final DeleteTag deleteTag;
   private final TagsCollection tags;
   private final String ref;
@@ -48,13 +48,13 @@
   @Inject
   TagApiImpl(
       ListTags listTags,
-      CreateTag.Factory createTagFactory,
+      CreateTag createTag,
       DeleteTag deleteTag,
       TagsCollection tags,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.listTags = listTags;
-    this.createTagFactory = createTagFactory;
+    this.createTag = createTag;
     this.deleteTag = deleteTag;
     this.tags = tags;
     this.project = project;
@@ -64,7 +64,7 @@
   @Override
   public TagApi create(TagInput input) throws RestApiException {
     try {
-      createTagFactory.create(ref).apply(project, input);
+      createTag.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create tag", e);
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index f3393c1..46a22c0 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
@@ -45,7 +47,7 @@
     final String n = params.getParameter(0);
     Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(n));
     if (!group.isPresent()) {
-      throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
+      throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
     setter.addValue(group.get().getId());
     return 1;
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 4d589a0..2dd0c7a 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -77,7 +79,7 @@
 
     GroupReference group = GroupBackends.findExactSuggestion(groupBackend, n);
     if (group == null) {
-      throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
+      throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
     setter.addValue(group.getUUID());
     return 1;
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index c7d3f73..2b66334 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountException;
@@ -76,11 +78,11 @@
           case OPENID:
           case OPENID_SSO:
           default:
-            throw new CmdLineException(owner, "user \"" + token + "\" not found");
+            throw new CmdLineException(owner, localizable("user \"%s\" not found"), token);
         }
       }
     } catch (OrmException e) {
-      throw new CmdLineException(owner, "database is down");
+      throw new CmdLineException(owner, localizable("database is down"));
     } catch (IOException e) {
       throw new CmdLineException(owner, "Failed to load account", e);
     } catch (ConfigInvalidException e) {
@@ -92,7 +94,7 @@
 
   private Account.Id createAccountByLdap(String user) throws CmdLineException, IOException {
     if (!ExternalId.isValidUsername(user)) {
-      throw new CmdLineException(owner, "user \"" + user + "\" not found");
+      throw new CmdLineException(owner, localizable("user \"%s\" not found"), user);
     }
 
     try {
@@ -100,7 +102,7 @@
       req.setSkipAuthentication(true);
       return accountManager.authenticate(req).getAccountId();
     } catch (AccountException e) {
-      throw new CmdLineException(owner, "user \"" + user + "\" not found");
+      throw new CmdLineException(owner, localizable("user \"%s\" not found"), user);
     }
   }
 
diff --git a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index adb5f63..13832fa 100644
--- a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -52,7 +54,7 @@
     final List<String> tokens = Splitter.on(',').splitToList(token);
     if (tokens.size() != 3) {
       throw new CmdLineException(
-          owner, "change should be specified as <project>,<branch>,<change-id>");
+          owner, localizable("change should be specified as <project>,<branch>,<change-id>"));
     }
 
     try {
@@ -64,12 +66,12 @@
         return 1;
       }
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, "Change-Id is not valid");
+      throw new CmdLineException(owner, localizable("Change-Id is not valid"));
     } catch (OrmException e) {
-      throw new CmdLineException(owner, "Database error: " + e.getMessage());
+      throw new CmdLineException(owner, localizable("Database error: %s"), e.getMessage());
     }
 
-    throw new CmdLineException(owner, "\"" + token + "\": change not found");
+    throw new CmdLineException(owner, localizable("\"%s\": change not found"), token);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
index cb70abf..84c1d88 100644
--- a/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -41,7 +43,7 @@
     try {
       id = PatchSet.Id.parse(token);
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, "\"" + token + "\" is not a valid patch set");
+      throw new CmdLineException(owner, localizable("\"%s\" is not a valid patch set"), token);
     }
 
     setter.addValue(id);
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index 7872812..223b112 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.ProjectUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -76,7 +78,7 @@
     try {
       state = projectCache.checkedGet(nameKey);
       if (state == null) {
-        throw new CmdLineException(owner, String.format("project %s not found", nameWithoutSuffix));
+        throw new CmdLineException(owner, localizable("project %s not found"), nameWithoutSuffix);
       }
       // Hidden projects(permitsRead = false) should only be accessible by the project owners.
       // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
@@ -86,10 +88,12 @@
           state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
       permissionBackend.currentUser().project(nameKey).check(permissionToCheck);
     } catch (AuthException e) {
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
+      throw new CmdLineException(
+          owner, localizable(new NoSuchProjectException(nameKey).getMessage()));
     } catch (PermissionBackendException | IOException e) {
       logger.atWarning().withCause(e).log("Cannot load project %s", nameWithoutSuffix);
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
+      throw new CmdLineException(
+          owner, localizable(new NoSuchProjectException(nameKey).getMessage()));
     }
 
     setter.addValue(state);
diff --git a/java/com/google/gerrit/server/args4j/SocketAddressHandler.java b/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
index 4325c00..198cf67 100644
--- a/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
+++ b/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -41,7 +43,7 @@
     try {
       setter.addValue(SocketUtil.parse(token, 0));
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, e.getMessage());
+      throw new CmdLineException(owner, localizable(e.getMessage()));
     }
     return 1;
   }
diff --git a/java/com/google/gerrit/server/audit/AuditModule.java b/java/com/google/gerrit/server/audit/AuditModule.java
index 0052aaa..df037b6 100644
--- a/java/com/google/gerrit/server/audit/AuditModule.java
+++ b/java/com/google/gerrit/server/audit/AuditModule.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.audit.group.GroupAuditListener;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.inject.AbstractModule;
 
 public class AuditModule extends AbstractModule {
@@ -24,6 +25,6 @@
   protected void configure() {
     DynamicSet.setOf(binder(), AuditListener.class);
     DynamicSet.setOf(binder(), GroupAuditListener.class);
-    bind(AuditService.class);
+    bind(GroupAuditService.class).to(AuditService.class);
   }
 }
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 9528670..2055e33 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -22,12 +22,13 @@
 import com.google.gerrit.server.audit.group.GroupAuditListener;
 import com.google.gerrit.server.audit.group.GroupMemberAuditEvent;
 import com.google.gerrit.server.audit.group.GroupSubgroupAuditEvent;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
 @Singleton
-public class AuditService {
+public class AuditService implements GroupAuditService {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DynamicSet<AuditListener> auditListeners;
@@ -47,6 +48,7 @@
     }
   }
 
+  @Override
   public void dispatchAddMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
@@ -63,6 +65,7 @@
     }
   }
 
+  @Override
   public void dispatchDeleteMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
@@ -79,6 +82,7 @@
     }
   }
 
+  @Override
   public void dispatchAddSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
@@ -95,6 +99,7 @@
     }
   }
 
+  @Override
   public void dispatchDeleteSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
new file mode 100644
index 0000000..d85668e
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -0,0 +1,97 @@
+java_library(
+    name = "audit",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/ssl",
+        "//java/org/apache/commons/net",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:autolink",
+        "//lib:automaton",
+        "//lib:blame-cache",
+        "//lib:flexmark",
+        "//lib:flexmark-ext-abbreviation",
+        "//lib:flexmark-ext-anchorlink",
+        "//lib:flexmark-ext-autolink",
+        "//lib:flexmark-ext-definition",
+        "//lib:flexmark-ext-emoji",
+        "//lib:flexmark-ext-escaped-character",
+        "//lib:flexmark-ext-footnotes",
+        "//lib:flexmark-ext-gfm-issues",
+        "//lib:flexmark-ext-gfm-strikethrough",
+        "//lib:flexmark-ext-gfm-tables",
+        "//lib:flexmark-ext-gfm-tasklist",
+        "//lib:flexmark-ext-gfm-users",
+        "//lib:flexmark-ext-ins",
+        "//lib:flexmark-ext-jekyll-front-matter",
+        "//lib:flexmark-ext-superscript",
+        "//lib:flexmark-ext-tables",
+        "//lib:flexmark-ext-toc",
+        "//lib:flexmark-ext-typographic",
+        "//lib:flexmark-ext-wikilink",
+        "//lib:flexmark-ext-yaml-front-matter",
+        "//lib:flexmark-formatter",
+        "//lib:flexmark-html-parser",
+        "//lib:flexmark-profile-pegdown",
+        "//lib:flexmark-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:juniversalchardet",
+        "//lib:mime-util",
+        "//lib:protobuf",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib:tukaani-xz",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/bouncycastle:bcpkix-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/commons:compress",
+        "//lib/commons:dbcp",
+        "//lib/commons:lang",
+        "//lib/commons:net",
+        "//lib/commons:validator",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jsoup",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-queryparser",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-tree",
+        "//lib/ow2:ow2-asm-util",
+        "//lib/prolog:runtime",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 8d12d32..ede8050 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -351,6 +351,7 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
+      logger.atFine().log("Loading account for username %s", username);
       return externalIds
           .get(ExternalId.Key.create(SCHEME_GERRIT, username))
           .map(ExternalId::accountId);
@@ -367,6 +368,7 @@
 
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
+      logger.atFine().log("Loading group for member with username %s", username);
       final DirContext ctx = helper.open();
       try {
         return helper.queryForGroups(ctx, username, null);
@@ -386,6 +388,7 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
+      logger.atFine().log("Loading groupDn %s", groupDn);
       final DirContext ctx = helper.open();
       try {
         Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 13a09a1..4c364c5 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.IntKeyCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 11f2034..3435652 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheStats;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.metrics.CallbackMetric;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
@@ -95,7 +96,7 @@
   }
 
   private static String metricNameOf(DynamicMap.Entry<Cache<?, ?>> e) {
-    if ("gerrit".equals(e.getPluginName())) {
+    if (PluginName.GERRIT.equals(e.getPluginName())) {
       return e.getExportName();
     }
     return String.format("plugin/%s/%s", e.getPluginName(), e.getExportName());
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index ca399e7..2878624 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.Weigher;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
diff --git a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index be06601..a7fdbbd 100644
--- a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.RemovalListener;
 import com.google.common.cache.RemovalNotification;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -36,7 +37,7 @@
 
   private final DynamicSet<CacheRemovalListener> listeners;
   private final String cacheName;
-  private String pluginName = "gerrit";
+  private String pluginName = PluginName.GERRIT;
 
   @Inject
   ForwardingRemovalListener(
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
index 0239ea2..5635f44 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
@@ -16,6 +16,7 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.time.Duration;
 
 /** Configure a persistent cache declared within a {@link CacheModule} instance. */
@@ -30,6 +31,9 @@
   PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
   @Override
+  PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
+
+  @Override
   PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
   PersistentCacheBinding<K, V> version(int version);
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheDef.java b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
index 9bd120f..8de685c 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheDef.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache;
 
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
 public interface PersistentCacheDef<K, V> extends CacheDef<K, V> {
   long diskLimit();
 
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 2db9e56..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -20,6 +20,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
@@ -63,6 +65,11 @@
   }
 
   @Override
+  public PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration) {
+    return (PersistentCacheBinding<K, V>) super.expireFromMemoryAfterAccess(duration);
+  }
+
+  @Override
   public PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
     return (PersistentCacheBinding<K, V>) super.weigher(clazz);
   }
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index fc57a11..f6418e3 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -8,6 +8,8 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:h2",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 78de67dd..48c0a5b 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -17,9 +17,9 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
 
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 9abccbc..af1228d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -28,6 +28,8 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -74,15 +76,17 @@
 
     if (cacheDir != null) {
       executor =
-          Executors.newFixedThreadPool(
-              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build());
+          new LoggingContextAwareExecutorService(
+              Executors.newFixedThreadPool(
+                  1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build()));
       cleanup =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("DiskCache-Prune-%d")
-                  .setDaemon(true)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("DiskCache-Prune-%d")
+                      .setDaemon(true)
+                      .build()));
     } else {
       executor = null;
       cleanup = null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index d7baee2..606fdf0 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -25,8 +25,8 @@
 import com.google.common.hash.BloomFilter;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.io.InvalidClassException;
@@ -48,7 +48,6 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.h2.jdbc.JdbcSQLException;
 
 /**
  * Hybrid in-memory and database backed cache built on H2.
@@ -236,6 +235,8 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
+      logger.atFine().log("Loading value for %s from cache", key);
+
       if (store.mightContain(key)) {
         ValueHolder<V> h = store.getIfPresent(key);
         if (h != null) {
@@ -341,8 +342,10 @@
               b.put(keyType.get(r, 1));
             }
           }
-        } catch (JdbcSQLException e) {
-          if (e.getCause() instanceof InvalidClassException) {
+        } catch (Exception e) {
+          if (Throwables.getCausalChain(e)
+              .stream()
+              .anyMatch(InvalidClassException.class::isInstance)) {
             // If deserialization failed using default Java serialization, this means we are using
             // the old serialVersionUID-based invalidation strategy. In that case, authors are
             // most likely bumping serialVersionUID rather than using the new versioning in the
@@ -531,8 +534,11 @@
         try (PreparedStatement ps = c.conn.prepareStatement("DELETE FROM data WHERE version!=?")) {
           ps.setInt(1, version);
           int oldEntries = ps.executeUpdate();
-          logger.atInfo().log(
-              "Pruned %d entries not matching version %d from cache %s", oldEntries, version, url);
+          if (oldEntries > 0) {
+            logger.atInfo().log(
+                "Pruned %d entries not matching version %d from cache %s",
+                oldEntries, version, url);
+          }
         }
         try (Statement s = c.conn.createStatement()) {
           // Compute size without restricting to version (although obsolete data was just pruned
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 44e2bb2..591883e 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -17,7 +17,7 @@
 import com.google.common.hash.Funnel;
 import com.google.common.hash.Funnels;
 import com.google.common.hash.PrimitiveSink;
-import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.io.IOException;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..957a153
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "serialize",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
index 59fc946..28cd6eb 100644
--- a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/CacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 08deecd..2d41f2c 100644
--- a/java/com/google/gerrit/server/cache/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 /**
  * Interface for serializing/deserializing a type to/from a persistent cache.
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/EnumCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
index c5be783..7856e55 100644
--- a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
similarity index 95%
rename from java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
index a07c004..cff8682 100644
--- a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
index 5eddb71..3195941 100644
--- a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/JavaCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
index 55358bc..ee71846 100644
--- a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
new file mode 100644
index 0000000..500875d
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2018 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.cache.serialize;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+public enum ObjectIdCacheSerializer implements CacheSerializer<ObjectId> {
+  INSTANCE;
+
+  @Override
+  public byte[] serialize(ObjectId object) {
+    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    object.copyRawTo(buf, 0);
+    return buf;
+  }
+
+  @Override
+  public ObjectId deserialize(byte[] in) {
+    if (in == null || in.length != Constants.OBJECT_ID_LENGTH) {
+      throw new IllegalArgumentException("Failed to deserialize ObjectId");
+    }
+    return ObjectId.fromRaw(in);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
similarity index 98%
rename from java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
rename to java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
index c6fc0b9..4e0b106 100644
--- a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
+++ b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
diff --git a/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
new file mode 100644
index 0000000..525b75b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 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.cache.serialize;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
+
+public enum StringCacheSerializer implements CacheSerializer<String> {
+  INSTANCE;
+
+  @Override
+  public byte[] serialize(String object) {
+    return serialize(UTF_8, object);
+  }
+
+  @VisibleForTesting
+  static byte[] serialize(Charset charset, String s) {
+    if (s.isEmpty()) {
+      return new byte[0];
+    }
+    try {
+      ByteBuffer buf =
+          charset
+              .newEncoder()
+              .onMalformedInput(CodingErrorAction.REPORT)
+              .onUnmappableCharacter(CodingErrorAction.REPORT)
+              .encode(CharBuffer.wrap(s));
+      byte[] result = new byte[buf.remaining()];
+      buf.get(result);
+      return result;
+    } catch (CharacterCodingException e) {
+      throw new IllegalStateException("Failed to serialize string", e);
+    }
+  }
+
+  @Override
+  public String deserialize(byte[] in) {
+    if (in.length == 0) {
+      return "";
+    }
+    try {
+      return UTF_8
+          .newDecoder()
+          .onMalformedInput(CodingErrorAction.REPORT)
+          .onUnmappableCharacter(CodingErrorAction.REPORT)
+          .decode(ByteBuffer.wrap(in))
+          .toString();
+    } catch (CharacterCodingException e) {
+      throw new IllegalStateException("Failed to deserialize string", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index ed412af..9a9f1ef 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:protobuf",
         "//lib/commons:lang3",
diff --git a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
index 5d41490..b339e24 100644
--- a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
+++ b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
@@ -18,12 +18,16 @@
 
 /** Static utilities for testing cache serializers. */
 public class CacheSerializerTestUtil {
-  public static ByteString bytes(int... ints) {
+  public static ByteString byteString(int... ints) {
+    return ByteString.copyFrom(byteArray(ints));
+  }
+
+  public static byte[] byteArray(int... ints) {
     byte[] bytes = new byte[ints.length];
     for (int i = 0; i < ints.length; i++) {
       bytes[i] = (byte) ints[i];
     }
-    return ByteString.copyFrom(bytes);
+    return bytes;
   }
 
   private CacheSerializerTestUtil() {}
diff --git a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
index 19c5b67..b902c1c 100644
--- a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
@@ -32,7 +32,7 @@
 /**
  * Subject about classes that are serialized into persistent caches.
  *
- * <p>Hand-written {@link com.google.gerrit.server.cache.CacheSerializer CacheSerializer}
+ * <p>Hand-written {@link com.google.gerrit.server.cache.serialize.CacheSerializer CacheSerializer}
  * implementations depend on the exact representation of the data stored in a class, so it is
  * important to verify any assumptions about the structure of the serialized classes. This class
  * contains assertions about serialized classes, and should be used for every class that has a
@@ -100,4 +100,11 @@
         .named("no-argument abstract methods on %s", actual().getName())
         .isEqualTo(expectedMethods);
   }
+
+  public void extendsClass(Type superclassType) {
+    isNotNull();
+    assertThat(actual().getGenericSuperclass())
+        .named("superclass of %s", actual().getName())
+        .isEqualTo(superclassType);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 8879235..fbabdd5 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -139,6 +139,7 @@
     copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
     copy.submitter = changeInfo.submitter;
+    copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
     return copy;
   }
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 08bcabe..392709e 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -20,7 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 /**
- * Represents change edit resource, that is actualy two kinds of resources:
+ * Represents change edit resource, that is actually two kinds of resources:
  *
  * <ul>
  *   <li>the change edit itself
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c6fe93b..38c97f7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -459,18 +460,24 @@
         .stream()
         .filter(
             accountId -> {
+              if (!projectState.statePermitsRead()) {
+                return false;
+              }
+
               try {
-                return permissionBackend
-                        .absentUser(accountId)
-                        .change(notes)
-                        .database(db)
-                        .test(ChangePermission.READ)
-                    && projectState.statePermitsRead();
+                permissionBackend
+                    .absentUser(accountId)
+                    .change(notes)
+                    .database(db)
+                    .check(ChangePermission.READ);
+                return true;
               } catch (PermissionBackendException e) {
                 logger.atWarning().withCause(e).log(
                     "Failed to check if account %d can see change %d",
                     accountId.get(), notes.getChangeId().get());
                 return false;
+              } catch (AuthException e) {
+                return false;
               }
             })
         .collect(toSet());
@@ -521,8 +528,7 @@
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
-        List<LabelType> labels =
-            projectState.getLabelTypes(change.getDest(), ctx.getUser()).getLabelTypes();
+        List<LabelType> labels = projectState.getLabelTypes(change.getDest()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
         Map<String, Short> oldApprovals = new HashMap<>();
         for (LabelType lt : labels) {
@@ -546,8 +552,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).project(ctx.getProject()).ref(refName);
     try {
       try (CommitReceivedEvent event =
           new CommitReceivedEvent(
@@ -559,7 +563,7 @@
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
-                perm,
+                permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
                 new Branch.NameKey(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8629898..173d1da 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -91,6 +91,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -123,7 +124,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -416,7 +416,7 @@
   }
 
   public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
-      throws OrmException {
+      throws PermissionBackendException {
     try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
@@ -434,7 +434,8 @@
     }
   }
 
-  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
+  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in)
+      throws OrmException, PermissionBackendException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
@@ -659,10 +660,9 @@
       // list permitted labels, since users can't vote on those patch sets.
       if (user.isIdentifiedUser()
           && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
-        PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
         out.permittedLabels =
             cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(perm, cd)
+                ? permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
 
@@ -890,7 +890,7 @@
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
       PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = labelTypes.byLabel(e.getKey());
         if (lt == null) {
@@ -1031,8 +1031,7 @@
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
-        PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
-        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
           ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
@@ -1107,8 +1106,7 @@
   }
 
   private Map<String, Collection<String>> permittedLabels(
-      PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException, PermissionBackendException {
+      Account.Id filterApprovalsBy, ChangeData cd) throws OrmException, PermissionBackendException {
     boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
     LabelTypes labelTypes = cd.getLabelTypes();
     Map<String, LabelType> toCheck = new HashMap<>();
@@ -1124,7 +1122,8 @@
     }
 
     Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
+    Set<LabelPermission.WithValue> can =
+        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -1140,7 +1139,7 @@
           boolean ok = can.contains(new LabelPermission.WithValue(type, v));
           if (isMerged) {
             if (labels == null) {
-              labels = currentLabels(perm, cd);
+              labels = currentLabels(filterApprovalsBy, cd);
             }
             short prev = labels.getOrDefault(type.getName(), (short) 0);
             ok &= v.getValue() >= prev;
@@ -1164,17 +1163,15 @@
     return permitted.asMap();
   }
 
-  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
+  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd)
       throws OrmException {
-    IdentifiedUser user = perm.user().asIdentifiedUser();
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa :
         approvalsUtil.byPatchSetUser(
             db.get(),
             lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            user,
             cd.change().currentPatchSetId(),
-            user.getAccountId(),
+            accountId,
             null,
             null)) {
       result.put(psa.getLabel(), psa.getValue());
@@ -1210,6 +1207,12 @@
     Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
     Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
+
+    // Check if the user has the permission to remove a reviewer. This means we can bypass the
+    // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
+    // permission checks.
+    boolean canRemoveAnyReviewer =
+        permissionBackendForChange(userProvider.get(), cd).test(ChangePermission.REMOVE_REVIEWER);
     for (LabelInfo label : labels) {
       if (label.all == null) {
         continue;
@@ -1217,8 +1220,9 @@
       for (ApprovalInfo ai : label.all) {
         Account.Id id = new Account.Id(ai._accountId);
 
-        if (removeReviewerControl.testRemoveReviewer(
-            cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+        if (canRemoveAnyReviewer
+            || removeReviewerControl.testRemoveReviewer(
+                cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
           removable.add(id);
         } else {
           fixed.add(id);
@@ -1235,7 +1239,8 @@
       for (AccountInfo ai : ccs) {
         if (ai._accountId != null) {
           Account.Id id = new Account.Id(ai._accountId);
-          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+          if (canRemoveAnyReviewer
+              || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
             removable.add(id);
           }
         }
@@ -1400,8 +1405,7 @@
         out.commitWithFooters =
             mergeUtilFactory
                 .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(
-                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 24685af..a6786d8 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.EnumCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 0aa6c2f..41757e6 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -404,7 +404,7 @@
       }
 
       List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
-      for (Ref ref : repo.getRefDatabase().getRefs(REFS_CHANGES).values()) {
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(REFS_CHANGES)) {
         if (!ref.getObjectId().equals(commit)) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index f4b7457..56cc8df 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 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.RevId;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -68,7 +69,12 @@
 
   private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
       throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(key, change.getProject());
+    return toFileInfoMap(change.getProject(), key);
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
+      throws PatchListNotAvailableException {
+    PatchList list = patchListCache.get(key, project);
 
     Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index 8f8925a..d5d54ec 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -63,13 +63,13 @@
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       for (ExternalIncludedIn ext : externalIncludedIn) {
         ListMultimap<String, String> extIncludedIns =
-            ext.getIncludedIn(project.get(), rev.name(), d.getTags(), d.getBranches());
+            ext.getIncludedIn(project.get(), rev.name(), d.tags(), d.branches());
         if (extIncludedIns != null) {
           external.putAll(extIncludedIns);
         }
       }
       return new IncludedInInfo(
-          d.getBranches(), d.getTags(), (!external.isEmpty() ? external.asMap() : null));
+          d.branches(), d.tags(), (!external.isEmpty() ? external.asMap() : null));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index c577f2d..62e9454 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -14,6 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -22,7 +29,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -85,19 +91,17 @@
 
   private Result resolve() throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
-    Collection<Ref> tags = refDb.getRefs(Constants.R_TAGS).values();
-    Collection<Ref> branches = refDb.getRefs(Constants.R_HEADS).values();
+    Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
+    Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
     List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
     allTagsAndBranches.addAll(tags);
     allTagsAndBranches.addAll(branches);
     parseCommits(allTagsAndBranches);
     Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
 
-    Result detail = new Result();
-    detail.setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
-    detail.setTags(getMatchingRefNames(allMatchingTagsAndBranches, tags));
-
-    return detail;
+    return new AutoValue_IncludedInResolver_Result(
+        getMatchingRefNames(allMatchingTagsAndBranches, branches),
+        getMatchingRefNames(allMatchingTagsAndBranches, tags));
   }
 
   private boolean includedInOne(Collection<Ref> refs) throws IOException {
@@ -151,15 +155,7 @@
    */
   private void partition(List<RevCommit> before, List<RevCommit> after) {
     int insertionPoint =
-        Collections.binarySearch(
-            tipsByCommitTime,
-            target,
-            new Comparator<RevCommit>() {
-              @Override
-              public int compare(RevCommit c1, RevCommit c2) {
-                return c1.getCommitTime() - c2.getCommitTime();
-              }
-            });
+        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
     if (insertionPoint < 0) {
       insertionPoint = -(insertionPoint + 1);
     }
@@ -175,15 +171,14 @@
    * Returns the short names of refs which are as well in the matchingRefs list as well as in the
    * allRef list.
    */
-  private static List<String> getMatchingRefNames(
+  private static ImmutableSortedSet<String> getMatchingRefNames(
       Set<String> matchingRefs, Collection<Ref> allRefs) {
-    List<String> refNames = Lists.newArrayListWithCapacity(matchingRefs.size());
-    for (Ref r : allRefs) {
-      if (matchingRefs.contains(r.getName())) {
-        refNames.add(Repository.shortenRefName(r.getName()));
-      }
-    }
-    return refNames;
+    return allRefs
+        .stream()
+        .map(Ref::getName)
+        .filter(matchingRefs::contains)
+        .map(Repository::shortenRefName)
+        .collect(toImmutableSortedSet(naturalOrder()));
   }
 
   /** Parse commit of ref and store the relation between ref and commit. */
@@ -211,43 +206,14 @@
       }
       commitToRef.put(commit, ref.getName());
     }
-    tipsByCommitTime = Lists.newArrayList(commitToRef.keySet());
-    sortOlderFirst(tipsByCommitTime);
+    tipsByCommitTime =
+        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
   }
 
-  private void sortOlderFirst(List<RevCommit> tips) {
-    Collections.sort(
-        tips,
-        new Comparator<RevCommit>() {
-          @Override
-          public int compare(RevCommit c1, RevCommit c2) {
-            return c1.getCommitTime() - c2.getCommitTime();
-          }
-        });
-  }
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableSortedSet<String> branches();
 
-  public static class Result {
-    private List<String> branches;
-    private List<String> tags;
-
-    public Result() {}
-
-    public void setBranches(List<String> b) {
-      Collections.sort(b);
-      branches = b;
-    }
-
-    public List<String> getBranches() {
-      return branches;
-    }
-
-    public void setTags(List<String> t) {
-      Collections.sort(t);
-      tags = t;
-    }
-
-    public List<String> getTags() {
-      return tags;
-    }
+    public abstract ImmutableSortedSet<String> tags();
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index f4dbf21..1ec1717 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -26,11 +26,8 @@
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -71,43 +68,25 @@
     }
   }
 
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ProjectCache projectCache;
 
   @Inject
-  LabelNormalizer(IdentifiedUser.GenericFactory userFactory, ProjectCache projectCache) {
-    this.userFactory = userFactory;
+  LabelNormalizer(ProjectCache projectCache) {
     this.projectCache = projectCache;
   }
 
   /**
-   * @param notes change containing the given approvals.
+   * @param notes change notes containing the given approvals.
    * @param approvals list of approvals.
    * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
    *     unknown labels are not included in the output.
-   * @throws OrmException
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
-      throws OrmException, IOException {
-    IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
-    return normalize(notes, user, approvals);
-  }
-
-  /**
-   * @param notes change notes containing the given approvals.
-   * @param user current user.
-   * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
-   *     unknown labels are not included in the output.
-   */
-  public Result normalize(
-      ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
       throws IOException {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
-    LabelTypes labelTypes =
-        projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes, user);
+    LabelTypes labelTypes = projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes);
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.getKey().getParentKey().getParentKey();
       checkArgument(
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 74810e9..2d00886 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -26,12 +26,12 @@
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
@@ -134,7 +134,7 @@
           .toString();
     }
 
-    static enum Serializer implements CacheSerializer<EntryKey> {
+    enum Serializer implements CacheSerializer<EntryKey> {
       INSTANCE;
 
       private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index b979240..8bd6c17 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -273,12 +273,7 @@
     change.setCurrentPatchSet(patchSetInfo);
     if (copyApprovals) {
       approvalCopier.copyInReviewDb(
-          db,
-          ctx.getNotes(),
-          ctx.getUser(),
-          patchSet,
-          ctx.getRevWalk(),
-          ctx.getRepoView().getConfig());
+          db, ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig());
     }
     if (changeMessage != null) {
       cmUtil.addChangeMessage(db, update, changeMessage);
@@ -314,7 +309,7 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException,
           OrmException {
     // Not allowed to create a new patch set if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(origNotes, ctx.getUser());
+    psUtil.checkPatchSetNotLocked(origNotes);
 
     if (checkAddPatchSetPermission) {
       permissionBackend
@@ -328,9 +323,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
-
     String refName = getPatchSetId().toRefName();
     try (CommitReceivedEvent event =
         new CommitReceivedEvent(
@@ -345,7 +337,7 @@
             ctx.getIdentifiedUser())) {
       commitValidatorsFactory
           .forGerritCommits(
-              perm,
+              permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
               origNotes.getChange().getDest(),
               ctx.getIdentifiedUser(),
               new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 850f33a..ddc9661 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -28,6 +28,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.List;
@@ -42,6 +43,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+@Singleton
 public class PureRevert {
   private final MergeUtil.Factory mergeUtilFactory;
   private final GitRepositoryManager repoManager;
@@ -108,8 +110,8 @@
               .create(projectCache.checkedGet(notes.getProjectName()))
               .newThreeWayMerger(oi, repo.getConfig());
       merger.setBase(claimedRevertCommit.getParent(0));
-      merger.merge(claimedRevertCommit, claimedOriginalCommit);
-      if (merger.getResultTreeId() == null) {
+      boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit);
+      if (!success || merger.getResultTreeId() == null) {
         // Merge conflict during rebase
         return new PureRevertInfo(false);
       }
@@ -117,7 +119,7 @@
       // Any differences between claimed original's parent and the rebase result indicate that the
       // claimedRevert is not a pure revert but made content changes
       try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
-        df.setRepository(repo);
+        df.setReader(oi.newReader(), repo.getConfig());
         List<DiffEntry> entries =
             df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
         return new PureRevertInfo(entries.isEmpty());
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 0bbaccd..1f216f0 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -165,8 +165,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(
-                  original, baseCommit, notes, changeOwner, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.getId());
     } else {
       newCommitMessage = original.getFullMessage();
     }
@@ -263,9 +262,9 @@
     ThreeWayMerger merger =
         newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
-    merger.merge(original, base);
+    boolean success = merger.merge(original, base);
 
-    if (merger.getResultTreeId() == null) {
+    if (!success || merger.getResultTreeId() == null) {
       throw new MergeConflictException(
           "The change could not be rebased due to a conflict during merge.");
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 778897e..52f3585 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.Address;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index cff1ac7..916a62b 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -34,7 +34,6 @@
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Deque;
 import java.util.HashSet;
 import java.util.List;
@@ -110,7 +109,7 @@
     for (Map.Entry<Project.NameKey, Collection<ChangeData>> e : byProject.asMap().entrySet()) {
       sortedByProject.add(sortProject(e.getKey(), e.getValue()));
     }
-    Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
+    sortedByProject.sort(PROJECT_LIST_SORTER);
     return Iterables.concat(sortedByProject);
   }
 
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 75a9323..49def5f 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -125,7 +125,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
     if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 16c7508..ffa7b5a 100644
--- a/java/com/google/gerrit/server/config/CacheResource.java
+++ b/java/com/google/gerrit/server/config/CacheResource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
@@ -52,7 +53,7 @@
   }
 
   public static String cacheNameOf(String plugin, String name) {
-    if ("gerrit".equals(plugin)) {
+    if (PluginName.GERRIT.equals(plugin)) {
       return name;
     }
     return plugin + "-" + name;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 57255a3..4c043b6 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
@@ -91,7 +92,6 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
@@ -171,6 +171,7 @@
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -245,6 +246,7 @@
     install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
@@ -301,7 +303,6 @@
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
     bind(AccountControl.Factory.class);
 
-    install(new AuditModule());
     bind(UiActions.class);
 
     bind(GitReferenceUpdated.class);
@@ -311,6 +312,7 @@
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
     DynamicSet.setOf(binder(), ChangeMergedListener.class);
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index 725a69a..0192ddd 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -14,43 +14,19 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.extensions.client.UiType;
 import org.eclipse.jgit.lib.Config;
 
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final boolean enablePolyGerrit;
   private final boolean enableGwtUi;
   private final boolean forcePolyGerritDev;
-  private final UiType defaultUi;
 
   public GerritOptions(Config cfg, boolean headless, boolean slave, boolean forcePolyGerritDev) {
     this.slave = slave;
-    this.enablePolyGerrit =
-        forcePolyGerritDev || cfg.getBoolean("gerrit", null, "enablePolyGerrit", true);
     this.enableGwtUi = cfg.getBoolean("gerrit", null, "enableGwtUi", true);
     this.forcePolyGerritDev = forcePolyGerritDev;
-    this.headless = headless || (!enableGwtUi && !enablePolyGerrit);
-
-    UiType defaultUi = enablePolyGerrit && !enableGwtUi ? UiType.POLYGERRIT : UiType.GWT;
-    String uiStr = firstNonNull(cfg.getString("gerrit", null, "ui"), defaultUi.name());
-    this.defaultUi = firstNonNull(UiType.parse(uiStr), UiType.NONE);
-
-    switch (defaultUi) {
-      case GWT:
-        checkArgument(enableGwtUi, "gerrit.ui = %s but GWT UI is disabled", defaultUi);
-        break;
-      case POLYGERRIT:
-        checkArgument(enablePolyGerrit, "gerrit.ui = %s but PolyGerrit is disabled", defaultUi);
-        break;
-      case NONE:
-      default:
-        throw new IllegalArgumentException("invalid gerrit.ui: " + uiStr);
-    }
+    this.headless = headless;
   }
 
   public boolean headless() {
@@ -65,15 +41,7 @@
     return !slave;
   }
 
-  public boolean enablePolyGerrit() {
-    return !headless && enablePolyGerrit;
-  }
-
   public boolean forcePolyGerritDev() {
     return !headless && forcePolyGerritDev;
   }
-
-  public UiType defaultUi() {
-    return defaultUi;
-  }
 }
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index f38572d..b5f09fd 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -36,6 +36,8 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.net.MalformedURLException;
+import java.net.URL;
 import org.eclipse.jgit.lib.Config;
 
 public class GitwebConfig {
@@ -178,7 +180,11 @@
   private final GitwebType type;
 
   @Inject
-  GitwebConfig(GitwebCgiConfig cgiConfig, @GerritServerConfig Config cfg) {
+  GitwebConfig(
+      GitwebCgiConfig cgiConfig,
+      @GerritServerConfig Config cfg,
+      @Nullable @CanonicalWebUrl String gerritUrl)
+      throws MalformedURLException {
     if (isDisabled(cfg)) {
       type = null;
       url = null;
@@ -191,7 +197,14 @@
         // Use an externally managed gitweb instance, and not an internal one.
         url = cfgUrl;
       } else {
-        url = firstNonNull(cfgUrl, "gitweb");
+        String baseGerritUrl;
+        if (gerritUrl != null) {
+          URL u = new URL(gerritUrl);
+          baseGerritUrl = u.getPath();
+        } else {
+          baseGerritUrl = "/";
+        }
+        url = firstNonNull(cfgUrl, baseGerritUrl + "gitweb");
       }
     }
   }
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 2e97c06..f552434 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -19,6 +19,7 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -82,14 +83,18 @@
       return MoreExecutors.newDirectExecutorService();
     }
     return MoreExecutors.listeningDecorator(
-        MoreExecutors.getExitingExecutorService(
-            new ThreadPoolExecutor(
-                1,
-                poolSize,
-                10,
-                TimeUnit.MINUTES,
-                new ArrayBlockingQueue<Runnable>(poolSize),
-                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
-                new ThreadPoolExecutor.CallerRunsPolicy())));
+        new LoggingContextAwareExecutorService(
+            MoreExecutors.getExitingExecutorService(
+                new ThreadPoolExecutor(
+                    1,
+                    poolSize,
+                    10,
+                    TimeUnit.MINUTES,
+                    new ArrayBlockingQueue<Runnable>(poolSize),
+                    new ThreadFactoryBuilder()
+                        .setNameFormat("ChangeUpdate-%d")
+                        .setDaemon(true)
+                        .build(),
+                    new ThreadPoolExecutor.CallerRunsPolicy()))));
   }
 }
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index ec76f50..fde5922 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -30,6 +30,7 @@
   public AccountAttribute assignee;
   public String url;
   public String commitMessage;
+  public List<String> hashtags;
 
   public Long createdOn;
   public Long lastUpdated;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index a7f9a05..2eb46f1 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -14,30 +14,33 @@
 
 package com.google.gerrit.server.documentation;
 
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.ALL;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.HARDWRAPS;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.SUPPRESS_ALL_HTML;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.pegdown.Extensions.ALL;
-import static org.pegdown.Extensions.HARDWRAPS;
-import static org.pegdown.Extensions.SUPPRESS_ALL_HTML;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.vladsch.flexmark.Extension;
+import com.vladsch.flexmark.ast.Block;
+import com.vladsch.flexmark.ast.Heading;
+import com.vladsch.flexmark.ast.Node;
+import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.util.options.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
-import org.pegdown.LinkRenderer;
-import org.pegdown.PegDownProcessor;
-import org.pegdown.ToHtmlSerializer;
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.TextNode;
 
 public class MarkdownFormatter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -48,9 +51,9 @@
     AtomicBoolean file = new AtomicBoolean();
     String src;
     try {
-      src = readPegdownCss(file);
+      src = readFlexMarkJavaCss(file);
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       src = "";
     }
     defaultCss = file.get() ? null : src;
@@ -61,9 +64,9 @@
       return defaultCss;
     }
     try {
-      return readPegdownCss(new AtomicBoolean());
+      return readFlexMarkJavaCss(new AtomicBoolean());
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       return "";
     }
   }
@@ -81,8 +84,28 @@
     return this;
   }
 
+  private MutableDataHolder markDownOptions() {
+    int options = ALL & ~(HARDWRAPS);
+    if (suppressHtml) {
+      options |= SUPPRESS_ALL_HTML;
+    }
+
+    MutableDataHolder optionsExt =
+        PegdownOptionsAdapter.flexmarkOptions(
+                options, MarkdownFormatterHeader.HeadingExtension.create())
+            .toMutable();
+
+    ArrayList<Extension> extensions = new ArrayList<>();
+    for (Extension extension : optionsExt.get(com.vladsch.flexmark.parser.Parser.EXTENSIONS)) {
+      extensions.add(extension);
+    }
+
+    return optionsExt;
+  }
+
   public byte[] markdownToDocHtml(String md, String charEnc) throws UnsupportedEncodingException {
-    RootNode root = parseMarkdown(md);
+    Node root = parseMarkdown(md);
+    HtmlRenderer renderer = HtmlRenderer.builder(markDownOptions()).build();
     String title = findTitle(root);
 
     StringBuilder html = new StringBuilder();
@@ -100,7 +123,7 @@
     html.append("\n</style>");
     html.append("</head>");
     html.append("<body>\n");
-    html.append(new ToHtmlSerializer(new LinkRenderer()).toHtml(root));
+    html.append(renderer.render(root));
     html.append("\n</body></html>");
     return html.toString().getBytes(charEnc);
   }
@@ -111,38 +134,36 @@
   }
 
   private String findTitle(Node root) {
-    if (root instanceof HeaderNode) {
-      HeaderNode h = (HeaderNode) root;
-      if (h.getLevel() == 1 && h.getChildren() != null && !h.getChildren().isEmpty()) {
-        StringBuilder b = new StringBuilder();
-        for (Node n : root.getChildren()) {
-          if (n instanceof TextNode) {
-            b.append(((TextNode) n).getText());
-          }
-        }
-        return b.toString();
+    if (root instanceof Heading) {
+      Heading h = (Heading) root;
+      if (h.getLevel() == 1 && h.hasChildren()) {
+        TextCollectingVisitor collectingVisitor = new TextCollectingVisitor();
+        return collectingVisitor.collectAndGetText(h);
       }
     }
 
-    for (Node n : root.getChildren()) {
-      String title = findTitle(n);
-      if (title != null) {
-        return title;
+    if (root instanceof Block && root.hasChildren()) {
+      Node child = root.getFirstChild();
+      while (child != null) {
+        String title = findTitle(child);
+        if (title != null) {
+          return title;
+        }
+        child = child.getNext();
       }
     }
+
     return null;
   }
 
-  private RootNode parseMarkdown(String md) {
-    int options = ALL & ~(HARDWRAPS);
-    if (suppressHtml) {
-      options |= SUPPRESS_ALL_HTML;
-    }
-    return new PegDownProcessor(options).parseMarkdown(md.toCharArray());
+  private Node parseMarkdown(String md) {
+    Parser parser = Parser.builder(markDownOptions()).build();
+    Node document = parser.parse(md);
+    return document;
   }
 
-  private static String readPegdownCss(AtomicBoolean file) throws IOException {
-    String name = "pegdown.css";
+  private static String readFlexMarkJavaCss(AtomicBoolean file) throws IOException {
+    String name = "flexmark-java.css";
     URL url = MarkdownFormatter.class.getResource(name);
     if (url == null) {
       throw new FileNotFoundException("Resource " + name);
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
new file mode 100644
index 0000000..00471fd
--- /dev/null
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2018 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.documentation;
+
+import com.vladsch.flexmark.ast.Heading;
+import com.vladsch.flexmark.ast.Node;
+import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
+import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
+import com.vladsch.flexmark.html.CustomNodeRenderer;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
+import com.vladsch.flexmark.html.HtmlWriter;
+import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
+import com.vladsch.flexmark.html.renderer.NodeRenderer;
+import com.vladsch.flexmark.html.renderer.NodeRendererContext;
+import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
+import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
+import com.vladsch.flexmark.profiles.pegdown.Extensions;
+import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.util.options.DataHolder;
+import com.vladsch.flexmark.util.options.MutableDataHolder;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class MarkdownFormatterHeader {
+  static class HeadingExtension implements HtmlRendererExtension {
+    @Override
+    public void rendererOptions(final MutableDataHolder options) {
+      // add any configuration settings to options you want to apply to everything, here
+    }
+
+    @Override
+    public void extend(final HtmlRenderer.Builder rendererBuilder, final String rendererType) {
+      rendererBuilder.nodeRendererFactory(new HeadingNodeRenderer.Factory());
+    }
+
+    static HeadingExtension create() {
+      return new HeadingExtension();
+    }
+  }
+
+  static class HeadingNodeRenderer implements NodeRenderer {
+    public HeadingNodeRenderer() {}
+
+    @Override
+    public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
+      return new HashSet<NodeRenderingHandler<? extends Node>>(
+          Arrays.asList(
+              new NodeRenderingHandler<>(
+                  AnchorLink.class,
+                  new CustomNodeRenderer<AnchorLink>() {
+                    @Override
+                    public void render(
+                        AnchorLink node, NodeRendererContext context, HtmlWriter html) {
+                      HeadingNodeRenderer.this.render(node, context);
+                    }
+                  }),
+              new NodeRenderingHandler<>(
+                  Heading.class,
+                  new CustomNodeRenderer<Heading>() {
+                    @Override
+                    public void render(Heading node, NodeRendererContext context, HtmlWriter html) {
+                      HeadingNodeRenderer.this.render(node, context, html);
+                    }
+                  })));
+    }
+
+    void render(final AnchorLink node, final NodeRendererContext context) {
+      Node parent = node.getParent();
+
+      if (parent instanceof Heading && ((Heading) parent).getLevel() == 1) {
+        // render without anchor link
+        context.renderChildren(node);
+      } else {
+        context.delegateRender();
+      }
+    }
+
+    static boolean haveExtension(int extensions, int flags) {
+      return (extensions & flags) != 0;
+    }
+
+    static boolean haveAllExtensions(int extensions, int flags) {
+      return (extensions & flags) == flags;
+    }
+
+    void render(final Heading node, final NodeRendererContext context, final HtmlWriter html) {
+      if (node.getLevel() == 1) {
+        // render without anchor link
+        final int extensions = context.getOptions().get(PegdownOptionsAdapter.PEGDOWN_EXTENSIONS);
+        if (context.getHtmlOptions().renderHeaderId
+            || haveExtension(extensions, Extensions.ANCHORLINKS)
+            || haveAllExtensions(
+                extensions, Extensions.EXTANCHORLINKS | Extensions.EXTANCHORLINKS_WRAP)) {
+          String id = context.getNodeId(node);
+          if (id != null) {
+            html.attr("id", id);
+          }
+        }
+
+        if (context.getHtmlOptions().sourcePositionParagraphLines) {
+          html.srcPos(node.getChars())
+              .withAttr()
+              .tagLine(
+                  "h" + node.getLevel(),
+                  new Runnable() {
+                    @Override
+                    public void run() {
+                      html.srcPos(node.getText()).withAttr().tag("span");
+                      context.renderChildren(node);
+                      html.tag("/span");
+                    }
+                  });
+        } else {
+          html.srcPos(node.getText())
+              .withAttr()
+              .tagLine(
+                  "h" + node.getLevel(),
+                  new Runnable() {
+                    @Override
+                    public void run() {
+                      context.renderChildren(node);
+                    }
+                  });
+        }
+      } else {
+        context.delegateRender();
+      }
+    }
+
+    public static class Factory implements DelegatingNodeRendererFactory {
+      @Override
+      public NodeRenderer create(final DataHolder options) {
+        return new HeadingNodeRenderer();
+      }
+
+      @Override
+      public Set<Class<? extends NodeRendererFactory>> getDelegates() {
+        Set<Class<? extends NodeRendererFactory>> delegates = new HashSet<>();
+        delegates.add(AnchorLinkNodeRenderer.Factory.class);
+        return delegates;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 80d1cd1..a8c463d 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -408,7 +408,7 @@
     }
 
     // Not allowed to edit if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(notes, currentUser.get());
+    patchSetUtil.checkPatchSetNotLocked(notes);
     try {
       permissionBackend
           .currentUser()
diff --git a/java/com/google/gerrit/server/events/ChangeDeletedEvent.java b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
new file mode 100644
index 0000000..63142fd
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class ChangeDeletedEvent extends ChangeEvent {
+  public static final String TYPE = "change-deleted";
+  public Supplier<AccountAttribute> deleter;
+
+  public ChangeDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 7d35070..1aff0fa 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -171,21 +171,31 @@
       return false;
     }
     ReviewDb db = dbProvider.get();
-    return permissionBackend
-        .user(user)
-        .change(notesFactory.createChecked(db, change))
-        .database(db)
-        .test(ChangePermission.READ);
+    try {
+      permissionBackend
+          .user(user)
+          .change(notesFactory.createChecked(db, change))
+          .database(db)
+          .check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
       throws PermissionBackendException {
     ProjectState pe = projectCache.get(branchName.getParentKey());
-    if (pe == null) {
+    if (pe == null || !pe.statePermitsRead()) {
       return false;
     }
-    return pe.statePermitsRead()
-        && permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
+
+    try {
+      permissionBackend.user(user).ref(branchName).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   protected boolean isVisibleTo(Event event, CurrentUser user)
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index f675dd5..fbce4b2 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -73,7 +73,6 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -90,7 +89,7 @@
   private final Emails emails;
   private final Provider<String> urlProvider;
   private final PatchListCache patchListCache;
-  private final PersonIdent myIdent;
+  private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeKindCache changeKindCache;
@@ -104,7 +103,7 @@
       Emails emails,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent myIdent,
+      @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
@@ -130,9 +129,9 @@
    * @param change
    * @return object suitable for serialization to JSON
    */
-  public ChangeAttribute asChangeAttribute(Change change) {
+  public ChangeAttribute asChangeAttribute(Change change, ChangeNotes notes) {
     try (ReviewDb db = schema.open()) {
-      return asChangeAttribute(db, change);
+      return asChangeAttribute(db, change, notes);
     } catch (OrmException e) {
       logger.atSevere().withCause(e).log("Cannot open database connection");
       return new ChangeAttribute();
@@ -171,6 +170,24 @@
   }
 
   /**
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
+   *
+   * @param db Review database
+   * @param change
+   * @param notes
+   * @return object suitable for serialization to JSON
+   */
+  public ChangeAttribute asChangeAttribute(ReviewDb db, Change change, ChangeNotes notes)
+      throws OrmException {
+    ChangeAttribute a = asChangeAttribute(db, change);
+    Set<String> hashtags = notes.load().getHashtags();
+    if (!hashtags.isEmpty()) {
+      a.hashtags = new ArrayList<>(hashtags.size());
+      a.hashtags.addAll(hashtags);
+    }
+    return a;
+  }
+  /**
    * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
    * suitable for serialization to JSON.
    *
@@ -311,10 +328,9 @@
       }
     }
     // Sort by original parent order.
-    Collections.sort(
-        ca.dependsOn,
+    ca.dependsOn.sort(
         comparing(
-            (DependencyAttribute d) -> {
+            d -> {
               for (int i = 0; i < parentNames.size(); i++) {
                 if (parentNames.get(i).equals(d.revision)) {
                   return i;
@@ -646,7 +662,7 @@
     a.reviewer =
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor())
-            : asAccountAttribute(myIdent);
+            : asAccountAttribute(myIdent.get());
     a.message = message.getMessage();
     return a;
   }
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index cd2b464..5498ec8 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -24,6 +24,7 @@
   static {
     register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
+    register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
diff --git a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
index af42b08..d03eda4 100644
--- a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class PrivateStateChangedEvent extends ChangeEvent {
+public class PrivateStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "private-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 9592238..97a3f39 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -75,6 +76,7 @@
 public class StreamEventsApiListener
     implements AssigneeChangedListener,
         ChangeAbandonedListener,
+        ChangeDeletedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
         WorkInProgressStateChangedListener,
@@ -95,6 +97,7 @@
     protected void configure() {
       DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
@@ -148,20 +151,16 @@
     }
   }
 
-  private Change getChange(ChangeInfo info) throws OrmException {
-    return getNotes(info).getChange();
-  }
-
   private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) throws OrmException {
     return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
   }
 
-  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change) {
+  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change, ChangeNotes notes) {
     return Suppliers.memoize(
         new Supplier<ChangeAttribute>() {
           @Override
           public ChangeAttribute get() {
-            return eventFactory.asChangeAttribute(change);
+            return eventFactory.asChangeAttribute(change, notes);
           }
         });
   }
@@ -257,10 +256,11 @@
   @Override
   public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       AssigneeChangedEvent event = new AssigneeChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
       event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
 
@@ -273,10 +273,11 @@
   @Override
   public void onTopicEdited(TopicEditedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       TopicChangedEvent event = new TopicChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
       event.oldTopic = ev.getOldTopic();
 
@@ -294,7 +295,7 @@
       PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.patchSet = patchSetAttributeSupplier(change, patchSet);
       event.uploader = accountAttributeSupplier(ev.getWho());
 
@@ -310,7 +311,7 @@
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
       ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
@@ -331,7 +332,7 @@
       Change change = notes.getChange();
       ReviewerAddedEvent event = new ReviewerAddedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       for (AccountInfo reviewer : ev.getReviewers()) {
         event.reviewer = accountAttributeSupplier(reviewer);
@@ -354,10 +355,11 @@
   @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
       HashtagsChangedEvent event = new HashtagsChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.editor = accountAttributeSupplier(ev.getWho());
       event.hashtags = hashtagArray(ev.getHashtags());
       event.added = hashtagArray(ev.getAddedHashtags());
@@ -402,7 +404,7 @@
       PatchSet ps = getPatchSet(notes, ev.getRevision());
       CommentAddedEvent event = new CommentAddedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.author = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, ps);
       event.comment = ev.getComment();
@@ -421,7 +423,7 @@
       Change change = notes.getChange();
       ChangeRestoredEvent event = new ChangeRestoredEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.restorer = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
@@ -439,7 +441,7 @@
       Change change = notes.getChange();
       ChangeMergedEvent event = new ChangeMergedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.submitter = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.newRev = ev.getNewRevisionId();
@@ -457,7 +459,7 @@
       Change change = notes.getChange();
       ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.abandoner = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
@@ -471,11 +473,14 @@
   @Override
   public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
@@ -486,11 +491,14 @@
   @Override
   public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
     try {
-      Change change = getChange(ev.getChange());
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
@@ -505,7 +513,7 @@
       Change change = notes.getChange();
       VoteDeletedEvent event = new VoteDeletedEvent(change);
 
-      event.change = changeAttributeSupplier(change);
+      event.change = changeAttributeSupplier(change, notes);
       event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.comment = ev.getMessage();
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
@@ -517,4 +525,20 @@
       logger.atSevere().withCause(e).log("Failed to dispatch event");
     }
   }
+
+  @Override
+  public void onChangeDeleted(ChangeDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeDeletedEvent event = new ChangeDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change, notes);
+      event.deleter = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      logger.atSevere().withCause(e).log("Failed to dispatch event");
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
index ad32672..5e52c7b 100644
--- a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class WorkInProgressStateChangedEvent extends ChangeEvent {
+public class WorkInProgressStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "wip-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
new file mode 100644
index 0000000..dd1dc07
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2018 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.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+
+@Singleton
+public class ChangeDeleted {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final DynamicSet<ChangeDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeDeleted(DynamicSet<ChangeDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, AccountState deleter, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      for (ChangeDeletedListener l : listeners) {
+        try {
+          l.onChangeDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      logger.atSevere().withCause(e).log("Couldn't fire event");
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
+    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+      super(change, deleter, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 74fba9a..5116708 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -121,8 +121,11 @@
 
   public void logEventListenerError(Object event, Object listener, Exception error) {
     logger.atWarning().log(
-        "Error in event listener %s for event %s: %s",
-        listener.getClass().getName(), event.getClass().getName(), error.getMessage());
+        "Error in event listener %s for event %s: %s - %s",
+        listener.getClass().getName(),
+        event.getClass().getName(),
+        error.getClass().getName(),
+        error.getMessage());
     logger.atFine().withCause(error).log(
         "Cause of error in event listener %s:", listener.getClass().getName());
   }
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index acd275d..2df56aa 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -40,12 +46,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (PrivateStateChangedListener l : listeners) {
         try {
           l.onPrivateStateChanged(event);
@@ -53,16 +64,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 3f9f35b..1c22561 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -41,12 +47,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (WorkInProgressStateChangedListener l : listeners) {
         try {
           l.onWorkInProgressStateChanged(event);
@@ -54,16 +65,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index f8cb4ce..af28bed3 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -169,7 +170,7 @@
 
     PrivateInternals_UiActionDescription.setMethod(dsc, e.getExportName().substring(0, d));
     PrivateInternals_UiActionDescription.setId(
-        dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+        dsc, PluginName.GERRIT.equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
     return dsc;
   }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index ac69ff1..513d909 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 
+/** Print a change description for use in git command-line progress. */
 public class DefaultChangeReportFormatter implements ChangeReportFormatter {
+  private static final int SUBJECT_MAX_LENGTH = 80;
+  private static final String SUBJECT_CROP_APPENDIX = "...";
+  private static final int SUBJECT_CROP_RANGE = 10;
+
   private final String canonicalWebUrl;
 
   @Inject
@@ -36,19 +41,37 @@
     return formatChangeUrl(canonicalWebUrl, input);
   }
 
-  @Override
-  public String changeClosed(ChangeReportFormatter.Input input) {
-    return String.format(
-        "change %s closed", ChangeUtil.formatChangeUrl(canonicalWebUrl, input.change()));
+  public static String formatChangeUrl(String canonicalWebUrl, Change change) {
+    return canonicalWebUrl + "c/" + change.getProject().get() + "/+/" + change.getChangeId();
   }
 
-  private String formatChangeUrl(String url, Input input) {
+  @Override
+  public String changeClosed(ChangeReportFormatter.Input input) {
+    return String.format("change %s closed", formatChangeUrl(canonicalWebUrl, input.change()));
+  }
+
+  protected String cropSubject(String subject) {
+    if (subject.length() > SUBJECT_MAX_LENGTH) {
+      int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
+      for (int cropPosition = maxLength;
+          cropPosition > maxLength - SUBJECT_CROP_RANGE;
+          cropPosition--) {
+        if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
+          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
+        }
+      }
+      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
+    }
+    return subject;
+  }
+
+  protected String formatChangeUrl(String url, Input input) {
     StringBuilder m =
         new StringBuilder()
             .append("  ")
-            .append(ChangeUtil.formatChangeUrl(url, input.change()))
+            .append(formatChangeUrl(url, input.change()))
             .append(" ")
-            .append(ChangeUtil.cropSubject(input.subject()));
+            .append(cropSubject(input.subject()));
     if (input.isEdit()) {
       m.append(" [EDIT]");
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 637be24..c035269 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -220,7 +219,7 @@
     } catch (IOException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
-    Collections.sort(result, CodeReviewCommit.ORDER);
+    result.sort(CodeReviewCommit.ORDER);
     return result;
   }
 
@@ -315,12 +314,10 @@
    *
    * @param n
    * @param notes
-   * @param user
    * @param psId
    * @return new message
    */
-  private String createDetailedCommitMessage(
-      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+  private String createDetailedCommitMessage(RevCommit n, ChangeNotes notes, PatchSet.Id psId) {
     Change c = notes.getChange();
     final List<FooterLine> footers = n.getFooterLines();
     final StringBuilder msgbuf = new StringBuilder();
@@ -361,7 +358,7 @@
 
     PatchSetApproval submitAudit = null;
 
-    for (PatchSetApproval a : safeGetApprovals(notes, user, psId)) {
+    for (PatchSetApproval a : safeGetApprovals(notes, psId)) {
       if (a.getValue() <= 0) {
         // Negative votes aren't counted.
         continue;
@@ -424,12 +421,7 @@
   }
 
   public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
-    return createCommitMessageOnSubmit(
-        n,
-        mergeTip,
-        n.notes(),
-        identifiedUserFactory.create(n.notes().getChange().getOwner()),
-        n.getPatchsetId());
+    return createCommitMessageOnSubmit(n, mergeTip, n.notes(), n.getPatchsetId());
   }
 
   /**
@@ -442,14 +434,13 @@
    * @param n
    * @param mergeTip
    * @param notes
-   * @param user
    * @param id
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
+      RevCommit n, RevCommit mergeTip, ChangeNotes notes, Id id) {
     return commitMessageGenerator.generate(
-        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
@@ -460,10 +451,9 @@
     return "Verified".equalsIgnoreCase(id.get());
   }
 
-  private Iterable<PatchSetApproval> safeGetApprovals(
-      ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+  private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(db.get(), notes, user, psId, null, null);
+      return approvalsUtil.byPatchSet(db.get(), notes, psId, null, null);
     } catch (OrmException e) {
       logger.atSevere().withCause(e).log("Can't read approval records for %s", psId);
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
index d2d3a39..d39cf12 100644
--- a/java/com/google/gerrit/server/git/NotifyConfig.java
+++ b/java/com/google/gerrit/server/git/NotifyConfig.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 1b83097..5f1d8c6 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -144,6 +144,7 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
+      logger.atFine().log("Loading changes of project %s", key);
       try (ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index b8acd0a..535644d 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -17,14 +17,12 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
@@ -35,17 +33,19 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        persist(CACHE_NAME, String.class, EntryVal.class);
+        persist(CACHE_NAME, String.class, TagSetHolder.class)
+            .version(1)
+            .keySerializer(StringCacheSerializer.INSTANCE)
+            .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
       }
     };
   }
 
-  private final Cache<String, EntryVal> cache;
-  private final Object createLock = new Object();
+  private final Cache<String, TagSetHolder> cache;
 
   @Inject
-  TagCache(@Named(CACHE_NAME) Cache<String, EntryVal> cache) {
+  TagCache(@Named(CACHE_NAME) Cache<String, TagSetHolder> cache) {
     this.cache = cache;
   }
 
@@ -68,62 +68,26 @@
     // never fail with an exception. Some of these references can be null
     // (e.g. not all projects are cached, or the cache is not current).
     //
-    EntryVal val = cache.getIfPresent(name.get());
-    if (val != null) {
-      TagSetHolder holder = val.holder;
-      if (holder != null) {
-        TagSet tags = holder.getTagSet();
-        if (tags != null) {
-          if (tags.updateFastForward(refName, oldValue, newValue)) {
-            cache.put(name.get(), val);
-          }
+    TagSetHolder holder = cache.getIfPresent(name.get());
+    if (holder != null) {
+      TagSet tags = holder.getTagSet();
+      if (tags != null) {
+        if (tags.updateFastForward(refName, oldValue, newValue)) {
+          cache.put(name.get(), holder);
         }
       }
     }
   }
 
   public TagSetHolder get(Project.NameKey name) {
-    EntryVal val = cache.getIfPresent(name.get());
-    if (val == null) {
-      synchronized (createLock) {
-        val = cache.getIfPresent(name.get());
-        if (val == null) {
-          val = new EntryVal();
-          val.holder = new TagSetHolder(name);
-          cache.put(name.get(), val);
-        }
-      }
+    try {
+      return cache.get(name.get(), () -> new TagSetHolder(name));
+    } catch (ExecutionException e) {
+      throw new IllegalStateException(e);
     }
-    return val.holder;
   }
 
   void put(Project.NameKey name, TagSetHolder tags) {
-    EntryVal val = new EntryVal();
-    val.holder = tags;
-    cache.put(name.get(), val);
-  }
-
-  public static class EntryVal implements Serializable {
-    static final long serialVersionUID = 1L;
-
-    transient TagSetHolder holder;
-
-    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
-      holder = new TagSetHolder(new Project.NameKey(in.readUTF()));
-      if (in.readBoolean()) {
-        TagSet tags = new TagSet(holder.getProjectName());
-        tags.readObject(in);
-        holder.setTagSet(tags);
-      }
-    }
-
-    private void writeObject(ObjectOutputStream out) throws IOException {
-      TagSet tags = holder.getTagSet();
-      out.writeUTF(holder.getProjectName().get());
-      out.writeBoolean(tags != null);
-      if (tags != null) {
-        tags.writeObject(out);
-      }
-    }
+    cache.put(name.get(), tags);
   }
 }
diff --git a/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
index 945e91e..58b4b8b 100644
--- a/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/java/com/google/gerrit/server/git/TagMatcher.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.server.git.TagSet.Tag;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.BitSet;
 import java.util.Collection;
@@ -51,7 +52,11 @@
   }
 
   public boolean isReachable(Ref tagRef) {
-    tagRef = db.peel(tagRef);
+    try {
+      tagRef = db.getRefDatabase().peel(tagRef);
+    } catch (IOException e) {
+      // Ignore
+    }
 
     ObjectId tagObj = tagRef.getPeeledObjectId();
     if (tagObj == null) {
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 10b3411..ce8814f 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.git;
 
-import static org.eclipse.jgit.lib.ObjectIdSerializer.readWithoutMarker;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
-
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 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.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.protobuf.ByteString;
 import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
 import java.util.BitSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -34,7 +37,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -48,15 +50,35 @@
   private final ObjectIdOwnerMap<Tag> tags;
 
   TagSet(Project.NameKey projectName) {
+    this(projectName, new HashMap<>(), new ObjectIdOwnerMap<>());
+  }
+
+  TagSet(Project.NameKey projectName, HashMap<String, CachedRef> refs, ObjectIdOwnerMap<Tag> tags) {
     this.projectName = projectName;
-    this.refs = new HashMap<>();
-    this.tags = new ObjectIdOwnerMap<>();
+    this.refs = refs;
+    this.tags = tags;
+  }
+
+  Project.NameKey getProjectName() {
+    return projectName;
   }
 
   Tag lookupTag(AnyObjectId id) {
     return tags.get(id);
   }
 
+  // Test methods have obtuse names in addition to annotations, since they expose mutable state
+  // which would be easy to corrupt.
+  @VisibleForTesting
+  Map<String, CachedRef> getRefsForTesting() {
+    return refs;
+  }
+
+  @VisibleForTesting
+  ObjectIdOwnerMap<Tag> getTagsForTesting() {
+    return tags;
+  }
+
   boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
     CachedRef ref = refs.get(refName);
     if (ref != null) {
@@ -157,13 +179,17 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
-      for (Ref ref : git.getRefDatabase().getRefs(RefDatabase.ALL).values()) {
+      for (Ref ref : git.getRefDatabase().getRefs()) {
         if (skip(ref)) {
           continue;
 
         } else if (isTag(ref)) {
           // For a tag, remember where it points to.
-          addTag(rw, git.peel(ref));
+          try {
+            addTag(rw, git.getRefDatabase().peel(ref));
+          } catch (IOException e) {
+            addTag(rw, ref);
+          }
 
         } else {
           // New reference to include in the set.
@@ -188,36 +214,46 @@
     }
   }
 
-  void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
-    int refCnt = in.readInt();
-    for (int i = 0; i < refCnt; i++) {
-      String name = in.readUTF();
-      int flag = in.readInt();
-      ObjectId id = readWithoutMarker(in);
-      refs.put(name, new CachedRef(flag, id));
-    }
+  static TagSet fromProto(TagSetProto proto) {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
 
-    int tagCnt = in.readInt();
-    for (int i = 0; i < tagCnt; i++) {
-      ObjectId id = readWithoutMarker(in);
-      BitSet flags = (BitSet) in.readObject();
-      tags.add(new Tag(id, flags));
-    }
+    HashMap<String, CachedRef> refs = Maps.newHashMapWithExpectedSize(proto.getRefCount());
+    proto
+        .getRefMap()
+        .forEach(
+            (n, cr) ->
+                refs.put(n, new CachedRef(cr.getFlag(), idConverter.fromByteString(cr.getId()))));
+    ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
+    proto
+        .getTagList()
+        .forEach(
+            t ->
+                tags.add(
+                    new Tag(
+                        idConverter.fromByteString(t.getId()),
+                        BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
+    return new TagSet(new Project.NameKey(proto.getProjectName()), refs, tags);
   }
 
-  void writeObject(ObjectOutputStream out) throws IOException {
-    out.writeInt(refs.size());
-    for (Map.Entry<String, CachedRef> e : refs.entrySet()) {
-      out.writeUTF(e.getKey());
-      out.writeInt(e.getValue().flag);
-      writeWithoutMarker(out, e.getValue().get());
-    }
-
-    out.writeInt(tags.size());
-    for (Tag tag : tags) {
-      writeWithoutMarker(out, tag);
-      out.writeObject(tag.refFlags);
-    }
+  TagSetProto toProto() {
+    ObjectIdConverter idConverter = ObjectIdConverter.create();
+    TagSetProto.Builder b = TagSetProto.newBuilder().setProjectName(projectName.get());
+    refs.forEach(
+        (n, cr) ->
+            b.putRef(
+                n,
+                CachedRefProto.newBuilder()
+                    .setId(idConverter.toByteString(cr.get()))
+                    .setFlag(cr.flag)
+                    .build()));
+    tags.forEach(
+        t ->
+            b.addTag(
+                TagProto.newBuilder()
+                    .setId(idConverter.toByteString(t))
+                    .setFlags(ByteString.copyFrom(t.refFlags.toByteArray()))
+                    .build()));
+    return b.build();
   }
 
   private boolean refresh(TagSet old, TagMatcher m) {
@@ -338,8 +374,17 @@
     return ref.getName().startsWith(Constants.R_TAGS);
   }
 
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("projectName", projectName)
+        .add("refs", refs)
+        .add("tags", tags)
+        .toString();
+  }
+
   static final class Tag extends ObjectIdOwnerMap.Entry {
-    private final BitSet refFlags;
+    @VisibleForTesting final BitSet refFlags;
 
     Tag(AnyObjectId id, BitSet flags) {
       super(id);
@@ -349,9 +394,15 @@
     boolean has(BitSet mask) {
       return refFlags.intersects(mask);
     }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
+    }
   }
 
-  private static final class CachedRef extends AtomicReference<ObjectId> {
+  @VisibleForTesting
+  static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
 
     final int flag;
@@ -364,6 +415,15 @@
       this.flag = flag;
       set(id);
     }
+
+    @Override
+    public String toString() {
+      ObjectId id = get();
+      return MoreObjects.toStringHelper(this)
+          .addValue(id != null ? id.name() : "null")
+          .add("flag", flag)
+          .toString();
+    }
   }
 
   private static final class TagWalk extends RevWalk {
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 3f08d10..4c0c035 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -16,7 +16,11 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -24,7 +28,8 @@
 public class TagSetHolder {
   private final Object buildLock = new Object();
   private final Project.NameKey projectName;
-  private volatile TagSet tags;
+
+  @Nullable private volatile TagSet tags;
 
   TagSetHolder(Project.NameKey projectName) {
     this.projectName = projectName;
@@ -94,4 +99,30 @@
       return cur;
     }
   }
+
+  enum Serializer implements CacheSerializer<TagSetHolder> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(TagSetHolder object) {
+      TagSetHolderProto.Builder b =
+          TagSetHolderProto.newBuilder().setProjectName(object.projectName.get());
+      TagSet tags = object.tags;
+      if (tags != null) {
+        b.setTags(tags.toProto());
+      }
+      return ProtoCacheSerializers.toByteArray(b.build());
+    }
+
+    @Override
+    public TagSetHolder deserialize(byte[] in) {
+      TagSetHolderProto proto =
+          ProtoCacheSerializers.parseUnchecked(TagSetHolderProto.parser(), in);
+      TagSetHolder holder = new TagSetHolder(new Project.NameKey(proto.getProjectName()));
+      if (proto.hasTags()) {
+        holder.tags = TagSet.fromProto(proto.getTags());
+      }
+      return holder;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
index 204a0d5..8c93833 100644
--- a/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/java/com/google/gerrit/server/git/TransferConfig.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.TimeUnit;
@@ -29,6 +28,7 @@
   private final PackConfig packConfig;
   private final long maxObjectSizeLimit;
   private final String maxObjectSizeLimitFormatted;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   @Inject
   TransferConfig(@GerritServerConfig Config cfg) {
@@ -43,6 +43,8 @@
                 TimeUnit.SECONDS);
     maxObjectSizeLimit = cfg.getLong("receive", "maxObjectSizeLimit", 0);
     maxObjectSizeLimitFormatted = cfg.getString("receive", null, "maxObjectSizeLimit");
+    inheritProjectMaxObjectSizeLimit =
+        cfg.getBoolean("receive", "inheritProjectMaxObjectSizeLimit", false);
 
     packConfig = new PackConfig();
     packConfig.setDeltaCompress(false);
@@ -67,13 +69,7 @@
     return maxObjectSizeLimitFormatted;
   }
 
-  public long getEffectiveMaxObjectSizeLimit(ProjectState p) {
-    long global = getMaxObjectSizeLimit();
-    long local = p.getMaxObjectSizeLimit();
-    if (global > 0 && local > 0) {
-      return Math.min(global, local);
-    }
-    // zero means "no limit", in this case the max is more limiting
-    return Math.max(global, local);
+  public boolean getInheritProjectMaxObjectSizeLimit() {
+    return inheritProjectMaxObjectSizeLimit;
   }
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 46518e5..a7336f0 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.CaseFormat;
 import com.google.common.base.Supplier;
 import com.google.common.flogger.FluentLogger;
@@ -24,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,6 +46,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
@@ -122,7 +127,7 @@
    * @param poolsize the size of the pool.
    * @param queueName the name of the queue.
    */
-  public ScheduledThreadPoolExecutor createQueue(int poolsize, String queueName) {
+  public ScheduledExecutorService createQueue(int poolsize, String queueName) {
     return createQueue(poolsize, queueName, Thread.NORM_PRIORITY, false);
   }
 
@@ -274,6 +279,75 @@
     }
 
     @Override
+    public void execute(Runnable command) {
+      super.execute(LoggingContext.copy(command));
+    }
+
+    @Override
+    public <T> Future<T> submit(Callable<T> task) {
+      return super.submit(LoggingContext.copy(task));
+    }
+
+    @Override
+    public <T> Future<T> submit(Runnable task, T result) {
+      return super.submit(LoggingContext.copy(task), result);
+    }
+
+    @Override
+    public Future<?> submit(Runnable task) {
+      return super.submit(LoggingContext.copy(task));
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+        throws InterruptedException {
+      return super.invokeAll(tasks.stream().map(LoggingContext::copy).collect(toList()));
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(
+        Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+        throws InterruptedException {
+      return super.invokeAll(
+          tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+    }
+
+    @Override
+    public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+        throws InterruptedException, ExecutionException {
+      return super.invokeAny(tasks.stream().map(LoggingContext::copy).collect(toList()));
+    }
+
+    @Override
+    public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+        throws InterruptedException, ExecutionException, TimeoutException {
+      return super.invokeAny(
+          tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+      return super.schedule(LoggingContext.copy(command), delay, unit);
+    }
+
+    @Override
+    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+      return super.schedule(LoggingContext.copy(callable), delay, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> scheduleAtFixedRate(
+        Runnable command, long initialDelay, long period, TimeUnit unit) {
+      return super.scheduleAtFixedRate(LoggingContext.copy(command), initialDelay, period, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> scheduleWithFixedDelay(
+        Runnable command, long initialDelay, long delay, TimeUnit unit) {
+      return super.scheduleWithFixedDelay(LoggingContext.copy(command), initialDelay, delay, unit);
+    }
+
+    @Override
     protected void terminated() {
       super.terminated();
       queues.remove(this);
@@ -367,6 +441,10 @@
 
         Task<V> task;
 
+        if (runnable instanceof LoggingContextAwareRunnable) {
+          runnable = ((LoggingContextAwareRunnable) runnable).unwrap();
+        }
+
         if (runnable instanceof ProjectRunnable) {
           task = new ProjectTask<>((ProjectRunnable) runnable, r, this, id);
         } else {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index ef25cd8..4c0378a 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.git.meta;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -122,10 +124,8 @@
     return buf.toString();
   }
 
-  protected static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
-    ArrayList<T> r = new ArrayList<>(m);
-    Collections.sort(r);
-    return r;
+  protected static <T extends Comparable<? super T>> ImmutableList<T> sort(Collection<T> m) {
+    return m.stream().sorted().collect(toImmutableList());
   }
 
   protected static String pad(int len, String src) {
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index da1f1ac..8b14177 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -17,7 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.LockFailureException;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -62,6 +64,8 @@
  * read from the repository, or format an update that can later be written back to the repository.
  */
 public abstract class VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /**
    * Path information that does not hold references to any repository data structures, allowing the
    * application to retain this object for long periods of time.
@@ -81,6 +85,7 @@
   /** The revision at which the data was loaded. Is null for data yet to be created. */
   @Nullable protected RevCommit revision;
 
+  protected Project.NameKey projectName;
   protected RevWalk rw;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
@@ -114,13 +119,15 @@
    * <p>The repository is not held after the call completes, allowing the application to retain this
    * object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param db repository to access.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db) throws IOException, ConfigInvalidException {
+  public void load(Project.NameKey projectName, Repository db)
+      throws IOException, ConfigInvalidException {
     Ref ref = db.getRefDatabase().exactRef(getRefName());
-    load(db, ref != null ? ref.getObjectId() : null);
+    load(projectName, db, ref != null ? ref.getObjectId() : null);
   }
 
   /**
@@ -133,15 +140,16 @@
    * <p>The repository is not held after the call completes, allowing the application to retain this
    * object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param db repository to access.
    * @param id revision to load.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db, @Nullable ObjectId id)
+  public void load(Project.NameKey projectName, Repository db, @Nullable ObjectId id)
       throws IOException, ConfigInvalidException {
     try (RevWalk walk = new RevWalk(db)) {
-      load(walk, id);
+      load(projectName, walk, id);
     }
   }
 
@@ -156,12 +164,15 @@
    * instance does not hold a reference to the walk or the repository after the call completes,
    * allowing the application to retain this object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param walk open walk to access to access.
    * @param id revision to load.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
+  public void load(Project.NameKey projectName, RevWalk walk, ObjectId id)
+      throws IOException, ConfigInvalidException {
+    this.projectName = projectName;
     this.rw = walk;
     this.reader = walk.getObjectReader();
     try {
@@ -174,11 +185,11 @@
   }
 
   public void load(MetaDataUpdate update) throws IOException, ConfigInvalidException {
-    load(update.getRepository());
+    load(update.getProjectName(), update.getRepository());
   }
 
   public void load(MetaDataUpdate update, ObjectId id) throws IOException, ConfigInvalidException {
-    load(update.getRepository(), id);
+    load(update.getProjectName(), update.getRepository(), id);
   }
 
   /**
@@ -481,6 +492,9 @@
       return new byte[] {};
     }
 
+    logger.atFine().log(
+        "Read file '%s' from ref '%s' of project '%s' from revision '%s'",
+        fileName, getRefName(), projectName, revision.name());
     try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
       if (tw != null) {
         ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
@@ -553,6 +567,8 @@
   }
 
   protected void saveFile(String fileName, byte[] raw) throws IOException {
+    logger.atFine().log(
+        "Save file '%s' in ref '%s' of project '%s'", fileName, getRefName(), projectName);
     DirCacheEditor editor = newTree.editor();
     if (raw != null && 0 < raw.length) {
       final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 01ce468..eb62d54 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -65,7 +65,13 @@
 import org.eclipse.jgit.transport.ReceiveCommand.Result;
 import org.eclipse.jgit.transport.ReceivePack;
 
-/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
+/**
+ * Hook that delegates to {@link ReceiveCommits} in a worker thread.
+ *
+ * <p>Since the work that {@link ReceiveCommits} does may take a long, potentially unbounded amount
+ * of time, it runs in the background so it can be monitored for timeouts and cancelled, and have
+ * stalls reported to the user from the main thread.
+ */
 public class AsyncReceiveCommits implements PreReceiveHook {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -88,6 +94,7 @@
       // Don't expose the binding for ReceiveCommits.Factory. All callers should
       // be using AsyncReceiveCommits.Factory instead.
       install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
+      install(new FactoryModuleBuilder().build(BranchCommitValidator.Factory.class));
     }
 
     @Provides
@@ -103,24 +110,25 @@
     final MultiProgressMonitor progress;
 
     private final Collection<ReceiveCommand> commands;
-    private final ReceiveCommits rc;
+    private final ReceiveCommits receiveCommits;
 
     private Worker(Collection<ReceiveCommand> commands) {
       this.commands = commands;
-      rc = factory.create(projectState, user, rp, allRefsWatcher, extraReviewers);
-      rc.init();
-      rc.setMessageSender(messageSender);
+      receiveCommits =
+          factory.create(
+              projectState, user, receivePack, allRefsWatcher, extraReviewers, messageSender);
+      receiveCommits.init();
       progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
     }
 
     @Override
     public void run() {
-      rc.processCommands(commands, progress);
+      receiveCommits.processCommands(commands, progress);
     }
 
     @Override
     public Project.NameKey getProjectNameKey() {
-      return rc.getProject().getNameKey();
+      return receiveCommits.getProject().getNameKey();
     }
 
     @Override
@@ -139,35 +147,35 @@
     }
 
     void sendMessages() {
-      rc.sendMessages();
+      receiveCommits.sendMessages();
     }
 
     private class MessageSenderOutputStream extends OutputStream {
       @Override
       public void write(int b) {
-        rc.getMessageSender().sendBytes(new byte[] {(byte) b});
+        receiveCommits.getMessageSender().sendBytes(new byte[] {(byte) b});
       }
 
       @Override
       public void write(byte[] what, int off, int len) {
-        rc.getMessageSender().sendBytes(what, off, len);
+        receiveCommits.getMessageSender().sendBytes(what, off, len);
       }
 
       @Override
       public void write(byte[] what) {
-        rc.getMessageSender().sendBytes(what);
+        receiveCommits.getMessageSender().sendBytes(what);
       }
 
       @Override
       public void flush() {
-        rc.getMessageSender().flush();
+        receiveCommits.getMessageSender().flush();
       }
     }
   }
 
   private final ReceiveCommits.Factory factory;
   private final PermissionBackend.ForProject perm;
-  private final ReceivePack rp;
+  private final ReceivePack receivePack;
   private final ExecutorService executor;
   private final RequestScopePropagator scopePropagator;
   private final ReceiveConfig receiveConfig;
@@ -211,18 +219,18 @@
     this.extraReviewers = extraReviewers;
 
     Project.NameKey projectName = projectState.getNameKey();
-    rp = new ReceivePack(repo);
-    rp.setAllowCreates(true);
-    rp.setAllowDeletes(true);
-    rp.setAllowNonFastForwards(true);
-    rp.setRefLogIdent(user.newRefLogIdent());
-    rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(projectState));
-    rp.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
-    rp.setRefFilter(new ReceiveRefFilter());
-    rp.setAllowPushOptions(true);
-    rp.setPreReceiveHook(this);
-    rp.setPostReceiveHook(lazyPostReceive.get());
+    receivePack = new ReceivePack(repo);
+    receivePack.setAllowCreates(true);
+    receivePack.setAllowDeletes(true);
+    receivePack.setAllowNonFastForwards(true);
+    receivePack.setRefLogIdent(user.newRefLogIdent());
+    receivePack.setTimeout(transferConfig.getTimeout());
+    receivePack.setMaxObjectSizeLimit(projectState.getEffectiveMaxObjectSizeLimit().value);
+    receivePack.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
+    receivePack.setRefFilter(new ReceiveRefFilter());
+    receivePack.setAllowPushOptions(true);
+    receivePack.setPreReceiveHook(this);
+    receivePack.setPostReceiveHook(lazyPostReceive.get());
 
     // If the user lacks READ permission, some references may be filtered and hidden from view.
     // Check objects mentioned inside the incoming pack file are reachable from visible refs.
@@ -231,7 +239,8 @@
       projectState.checkStatePermitsRead();
       this.perm.check(ProjectPermission.READ);
     } catch (AuthException | ResourceConflictException e) {
-      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
+      receivePack.setCheckReferencedObjectsAreReachable(
+          receiveConfig.checkReferencedObjectsAreReachable);
     }
 
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
@@ -241,7 +250,7 @@
         new DefaultAdvertiseRefsHook(perm, RefFilterOptions.builder().setFilterMeta(true).build()));
     advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
     advHooks.add(new HackPushNegotiateHook());
-    rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
+    receivePack.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
   }
 
   /** Determine if the user can upload commits. */
@@ -288,6 +297,6 @@
   }
 
   public ReceivePack getReceivePack() {
-    return rp;
+    return receivePack;
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index fddb9d6..f1c604b 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
new file mode 100644
index 0000000..24b6ab1
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2018 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.receive;
+
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.ssh.SshInfo;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Validates single commits for a branch. */
+public class BranchCommitValidator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final IdentifiedUser user;
+  private final PermissionBackend.ForProject permissions;
+  private final Project project;
+  private final Branch.NameKey branch;
+  private final SshInfo sshInfo;
+
+  interface Factory {
+    BranchCommitValidator create(
+        ProjectState projectState, Branch.NameKey branch, IdentifiedUser user);
+  }
+
+  @Inject
+  BranchCommitValidator(
+      CommitValidators.Factory commitValidatorsFactory,
+      PermissionBackend permissionBackend,
+      SshInfo sshInfo,
+      @Assisted ProjectState projectState,
+      @Assisted Branch.NameKey branch,
+      @Assisted IdentifiedUser user) {
+    this.sshInfo = sshInfo;
+    this.user = user;
+    this.branch = branch;
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    project = projectState.getProject();
+    permissions = permissionBackend.user(user).project(project.getNameKey());
+  }
+
+  /**
+   * Validates a single commit. If the commit does not validate, the command is rejected.
+   *
+   * @param objectReader the object reader to use.
+   * @param cmd the ReceiveCommand executing the push.
+   * @param commit the commit being validated.
+   * @param isMerged whether this is a merge commit created by magicBranch --merge option
+   * @param change the change for which this is a new patchset.
+   */
+  public boolean validCommit(
+      ObjectReader objectReader,
+      ReceiveCommand cmd,
+      RevCommit commit,
+      boolean isMerged,
+      List<ValidationMessage> messages,
+      NoteMap rejectCommits,
+      @Nullable Change change)
+      throws IOException {
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, branch.get(), objectReader, commit, user)) {
+      CommitValidators validators;
+      if (isMerged) {
+        validators =
+            commitValidatorsFactory.forMergedCommits(permissions, branch, user.asIdentifiedUser());
+      } else {
+        validators =
+            commitValidatorsFactory.forReceiveCommits(
+                permissions,
+                branch,
+                user.asIdentifiedUser(),
+                sshInfo,
+                rejectCommits,
+                receiveEvent.revWalk,
+                change);
+      }
+
+      for (CommitValidationMessage m : validators.validate(receiveEvent)) {
+        messages.add(
+            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.isError()));
+      }
+    } catch (CommitValidationException e) {
+      logger.atFine().log("Commit validation failed on %s", commit.name());
+      for (CommitValidationMessage m : e.getMessages()) {
+        // The non-error messages may contain background explanation for the
+        // fatal error, so have to preserve all messages.
+        messages.add(
+            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.isError()));
+      }
+      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage()));
+      return false;
+    }
+    return true;
+  }
+
+  private String messageForCommit(RevCommit c, String msg) {
+    return String.format("commit %s: %s", c.abbreviate(RevId.ABBREV_LEN).name(), msg);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/MessageSender.java b/java/com/google/gerrit/server/git/receive/MessageSender.java
index a338021..1f66570 100644
--- a/java/com/google/gerrit/server/git/receive/MessageSender.java
+++ b/java/com/google/gerrit/server/git/receive/MessageSender.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.git.receive;
 
+import com.google.gerrit.server.UsedAt;
+
 /**
  * Interface used by {@link ReceiveCommits} for send messages over the wire during {@code
  * receive-pack}.
  */
+@UsedAt(UsedAt.Project.GOOGLE)
 public interface MessageSender {
   void sendMessage(String what);
 
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 568b966..4b475f9 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 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;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
@@ -23,7 +24,7 @@
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
 import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
@@ -39,13 +40,13 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
 import com.google.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
@@ -57,11 +58,11 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -81,7 +82,6 @@
 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.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -93,20 +93,15 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ChangeReportFormatter;
 import com.google.gerrit.server.git.GroupCollector;
@@ -116,13 +111,13 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -132,6 +127,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.CreateRefControl;
@@ -142,7 +138,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleException;
@@ -159,7 +154,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gwtorm.server.OrmException;
@@ -211,37 +205,24 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
 
-/** Receives change upload using the Git receive-pack protocol. */
+/**
+ * Receives change upload using the Git receive-pack protocol.
+ *
+ * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
+ * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
+ * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
+ * result in updates to reviews, through the autoclose mechanism.
+ */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private enum ReceiveError {
-    CONFIG_UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "Configuration changes can only be pushed by project owners\n"
-            + "who also have 'Push' rights on "
-            + RefNames.REFS_CONFIG),
-    UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "To push into this reference you need 'Push' rights."),
-    DELETE(
-        "You need 'Delete Reference' rights or 'Push' rights with the \n"
-            + "'Force Push' flag set to delete references."),
-    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-    CODE_REVIEW(
-        "You need 'Push' rights to upload code review requests.\n"
-            + "Verify that you are pushing to the right branch.");
-
-    private final String value;
-
-    ReceiveError(String value) {
-      this.value = value;
-    }
-
-    String get() {
-      return value;
-    }
-  }
+  private static final String CODE_REVIEW_ERROR =
+      "You need 'Push' rights to upload code review requests.\n"
+          + "Verify that you are pushing to the right branch.";
+  private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'";
+  private static final String CANNOT_DELETE_CONFIG =
+      "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
 
   interface Factory {
     ReceiveCommits create(
@@ -249,18 +230,19 @@
         IdentifiedUser user,
         ReceivePack receivePack,
         AllRefsWatcher allRefsWatcher,
-        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers,
+        MessageSender messageSender);
   }
 
   private class ReceivePackMessageSender implements MessageSender {
     @Override
     public void sendMessage(String what) {
-      rp.sendMessage(what);
+      receivePack.sendMessage(what);
     }
 
     @Override
     public void sendError(String what) {
-      rp.sendError(what);
+      receivePack.sendError(what);
     }
 
     @Override
@@ -271,7 +253,7 @@
     @Override
     public void sendBytes(byte[] what, int off, int len) {
       try {
-        rp.getMessageOutputStream().write(what, off, len);
+        receivePack.getMessageOutputStream().write(what, off, len);
       } catch (IOException e) {
         // Ignore write failures (matching JGit behavior).
       }
@@ -280,7 +262,7 @@
     @Override
     public void flush() {
       try {
-        rp.getMessageOutputStream().flush();
+        receivePack.getMessageOutputStream().flush();
       } catch (IOException e) {
         // Ignore write failures (matching JGit behavior).
       }
@@ -307,7 +289,6 @@
 
   // Injected fields.
   private final AccountResolver accountResolver;
-  private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeEditUtil editUtil;
@@ -316,7 +297,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeReportFormatter changeFormatter;
   private final CmdLineParser.Factory optionParserFactory;
-  private final CommitValidators.Factory commitValidatorsFactory;
+  private final BranchCommitValidator.Factory commitValidatorFactory;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -338,67 +319,50 @@
   private final ReviewDb db;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SshInfo sshInfo;
   private final SubmoduleOp.Factory subOpFactory;
   private final TagCache tagCache;
-  private final String canonicalWebUrl;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
   private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
   private final ProjectState projectState;
   private final IdentifiedUser user;
-  private final ReceivePack rp;
+  private final ReceivePack receivePack;
 
   // Immutable fields derived from constructor arguments.
+  private final boolean allowProjectOwnersToChangeParent;
   private final boolean allowPushToRefsChanges;
   private final LabelTypes labelTypes;
   private final NoteMap rejectCommits;
   private final PermissionBackend.ForProject permissions;
   private final Project project;
   private final Repository repo;
-  private final RequestId receiveId;
 
   // Collections populated during processing.
   private final List<UpdateGroupsRequest> updateGroups;
   private final List<ValidationMessage> messages;
-  private final ListMultimap<ReceiveError, String> errors;
+  /** Multimap of error text to refnames that produced that error. */
+  private final ListMultimap<String, String> errors;
+
   private final ListMultimap<String, String> pushOptions;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
-  private final Set<ObjectId> validCommits;
-
-  /**
-   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
-   * provided over the wire.
-   *
-   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
-   * creating patch set refs.
-   */
-  private final List<ReceiveCommand> actualCommands;
 
   // Collections lazily populated during processing.
-  private List<CreateRequest> newChanges;
   private ListMultimap<Change.Id, Ref> refsByChange;
   private ListMultimap<ObjectId, Ref> refsById;
 
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
-  private String setFullNameTo;
   private boolean setChangeAsPrivate;
   private Optional<NoteDbPushOption> noteDbPushOption;
+  private Optional<String> tracePushOption;
 
-  // Handles for outputting back over the wire to the end user.
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
   private MessageSender messageSender;
 
   @Inject
   ReceiveCommits(
       AccountResolver accountResolver,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
       @GerritServerConfig Config cfg,
@@ -408,7 +372,7 @@
       ChangeNotes.Factory notesFactory,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
       CmdLineParser.Factory optionParserFactory,
-      CommitValidators.Factory commitValidatorsFactory,
+      BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
@@ -430,24 +394,22 @@
       ReviewDb db,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      SshInfo sshInfo,
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
       @Assisted AllRefsWatcher allRefsWatcher,
-      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers,
+      @Nullable @Assisted MessageSender messageSender)
       throws IOException {
     // Injected fields.
     this.accountResolver = accountResolver;
-    this.accountsUpdateProvider = accountsUpdateProvider;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeInserterFactory = changeInserterFactory;
-    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.commitValidatorFactory = commitValidatorFactory;
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.db = db;
@@ -473,7 +435,6 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.sshInfo = sshInfo;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
 
@@ -482,7 +443,7 @@
     this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
     this.projectState = projectState;
     this.user = user;
-    this.rp = rp;
+    this.receivePack = rp;
 
     // Immutable fields derived from constructor arguments.
     allowPushToRefsChanges = cfg.getBoolean("receive", "allowPushToRefsChanges", false);
@@ -490,45 +451,33 @@
     project = projectState.getProject();
     labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
-    receiveId = RequestId.forProject(project.getNameKey());
     rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
-    this.canonicalWebUrl = canonicalWebUrl;
 
     // Collections populated during processing.
-    actualCommands = new ArrayList<>();
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
     messages = new ArrayList<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
     updateGroups = new ArrayList<>();
-    validCommits = new HashSet<>();
 
-    // Collections lazily populated during processing.
-    newChanges = Collections.emptyList();
+    this.allowProjectOwnersToChangeParent =
+        cfg.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
 
     // Other settings populated during processing.
     newChangeForAllNotInTarget =
         projectState.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET);
 
     // Handles for outputting back over the wire to the end user.
-    messageSender = new ReceivePackMessageSender();
+    this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
   }
 
   void init() {
     for (ReceivePackInitializer i : initializers) {
-      i.init(projectState.getNameKey(), rp);
+      i.init(projectState.getNameKey(), receivePack);
     }
   }
 
-  /** Set a message sender for this operation. */
-  void setMessageSender(MessageSender ms) {
-    messageSender = ms != null ? ms : new ReceivePackMessageSender();
-  }
-
   MessageSender getMessageSender() {
-    if (messageSender == null) {
-      setMessageSender(null);
-    }
     return messageSender;
   }
 
@@ -555,44 +504,176 @@
   }
 
   void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    newProgress = progress.beginSubTask("new", UNKNOWN);
-    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-    closeProgress = progress.beginSubTask("closed", UNKNOWN);
-    commandProgress = progress.beginSubTask("refs", UNKNOWN);
+    Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+    commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+    processCommandsUnsafe(commands, progress);
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() == NOT_ATTEMPTED) {
+        cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+      }
+    }
 
-    try {
-      parsePushOptions();
-      parseCommands(commands);
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      for (ReceiveCommand cmd : actualCommands) {
+    // This sends error messages before the 'done' string of the progress monitor is sent.
+    // Currently, the test framework relies on this ordering to understand if pushes completed
+    // successfully.
+    sendErrorMessages();
+
+    commandProgress.end();
+    progress.end();
+  }
+
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    parsePushOptions();
+    try (TraceContext traceContext =
+        TraceContext.newTrace(
+            tracePushOption.isPresent(),
+            tracePushOption.orElse(null),
+            (tagName, traceId) -> addMessage(tagName + ": " + traceId))) {
+      traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
+
+      // Log the push options here, rather than in parsePushOptions(), so that they are included
+      // into the trace if tracing is enabled.
+      logger.atFine().log("push options: %s", receivePack.getPushOptions());
+
+      if (!projectState.getProject().getState().permitsWrite()) {
+        for (ReceiveCommand cmd : commands) {
+          reject(cmd, "prohibited by Gerrit: project state does not permit write");
+        }
+        return;
+      }
+
+      logger.atFine().log("Parsing %d commands", commands.size());
+
+      List<ReceiveCommand> magicCommands = new ArrayList<>();
+      List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
+      List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+      for (ReceiveCommand cmd : commands) {
+        if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+          magicCommands.add(cmd);
+        } else if (isDirectChangesPush(cmd.getRefName())) {
+          directPatchSetPushCommands.add(cmd);
+        } else {
+          regularCommands.add(cmd);
+        }
+      }
+
+      int commandTypes =
+          (magicCommands.isEmpty() ? 0 : 1)
+              + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
+              + (regularCommands.isEmpty() ? 0 : 1);
+
+      if (commandTypes > 1) {
+        for (ReceiveCommand cmd : commands) {
+          if (cmd.getResult() == NOT_ATTEMPTED) {
+            cmd.setResult(REJECTED_OTHER_REASON, "cannot combine normal pushes and magic pushes");
+          }
+        }
+        return;
+      }
+
+      try {
+        if (!regularCommands.isEmpty()) {
+          handleRegularCommands(regularCommands, progress);
+          return;
+        }
+
+        for (ReceiveCommand cmd : directPatchSetPushCommands) {
+          parseDirectChangesPush(cmd);
+        }
+
+        boolean first = true;
+        for (ReceiveCommand cmd : magicCommands) {
+          if (first) {
+            parseMagicBranch(cmd);
+            first = false;
+          } else {
+            reject(cmd, "duplicate request");
+          }
+        }
+      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+        return;
+      }
+
+      Task newProgress = progress.beginSubTask("new", UNKNOWN);
+      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+      List<CreateRequest> newChanges = Collections.emptyList();
+      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+        newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+      }
+
+      // Commit validation has already happened, so any changes without Change-Id are for the
+      // deprecated feature.
+      warnAboutMissingChangeId(newChanges);
+      preparePatchSetsForReplace(newChanges);
+      insertChangesAndPatchSets(newChanges, replaceProgress);
+      newProgress.end();
+      replaceProgress.end();
+      queueSuccessMessages(newChanges);
+      refsPublishDeprecationWarning();
+    }
+  }
+
+  private void refsPublishDeprecationWarning() {
+    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+    if (magicBranch != null && magicBranch.publish) {
+      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
+    }
+  }
+
+  private void sendErrorMessages() {
+    if (!errors.isEmpty()) {
+      logger.atFine().log("Handling error conditions: %s", errors.keySet());
+      for (String error : errors.keySet()) {
+        receivePack.sendMessage("error: " + buildError(error, errors.get(error)));
+      }
+      receivePack.sendMessage(String.format("User: %s", user.getLoggableName()));
+      receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+    }
+  }
+
+  private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
+      throws PermissionBackendException, IOException, NoSuchProjectException {
+    for (ReceiveCommand cmd : cmds) {
+      parseRegularCommand(cmd);
+    }
+
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRefLogMessage("push");
+
+      int added = 0;
+      for (ReceiveCommand cmd : cmds) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+          added++;
+        }
+      }
+      logger.atFine().log("Added %d additional ref updates", added);
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      for (ReceiveCommand cmd : cmds) {
         if (cmd.getResult() == NOT_ATTEMPTED) {
           cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
-      logError(String.format("Failed to process refs in %s", project.getName()), err);
-    }
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace();
-    insertChangesAndPatchSets();
-    newProgress.end();
-    replaceProgress.end();
-
-    if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: %s", errors.keySet());
-      for (ReceiveError error : errors.keySet()) {
-        rp.sendMessage(buildError(error, errors.get(error)));
-      }
-      rp.sendMessage(String.format("User: %s", user.getLoggableName()));
-      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+      logger.atFine().withCause(e).log("update failed:");
     }
 
     Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : actualCommands) {
+    for (ReceiveCommand c : cmds) {
       // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-      // should happen in this loop are things that can't happen within one BatchUpdate because they
-      // involve kicking off an additional BatchUpdate.
+      // should happen in this loop are things that can't happen within one BatchUpdate because
+      // they involve kicking off an additional BatchUpdate.
       if (c.getResult() != OK) {
         continue;
       }
@@ -601,7 +682,9 @@
           case CREATE:
           case UPDATE:
           case UPDATE_NONFASTFORWARD:
-            autoCloseChanges(c);
+            Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+            autoCloseChanges(c, closeProgress);
+            closeProgress.end();
             branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
             break;
 
@@ -614,26 +697,34 @@
     // Update superproject gitlinks if required.
     if (!branches.isEmpty()) {
       try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        orm.setContext(db, TimeUtil.nowTs(), user);
         SubmoduleOp op = subOpFactory.create(branches, orm);
         op.updateSuperProjects();
       } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
+        logger.atSevere().withCause(e).log("Can't update the superprojects");
       }
     }
-
-    // Update account info with details discovered during commit walking.
-    updateAccountInfo();
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages();
   }
 
-  private void reportMessages() {
+  /** Appends messages for successful change creation/updates. */
+  private void queueSuccessMessages(List<CreateRequest> newChanges) {
     List<CreateRequest> created =
         newChanges.stream().filter(r -> r.change != null).collect(toList());
+    List<ReplaceRequest> updated =
+        replaceByChange
+            .values()
+            .stream()
+            .filter(r -> r.inputCommand.getResult() == OK)
+            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+            .collect(toList());
+
+    if (created.isEmpty() && updated.isEmpty()) {
+      return;
+    }
+
+    addMessage("");
+    addMessage("SUCCESS");
+
     if (!created.isEmpty()) {
       addMessage("");
       addMessage("New Changes:");
@@ -642,16 +733,8 @@
             changeFormatter.newChange(
                 ChangeReportFormatter.Input.builder().setChange(c.change).build()));
       }
-      addMessage("");
     }
 
-    List<ReplaceRequest> updated =
-        replaceByChange
-            .values()
-            .stream()
-            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
-            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
-            .collect(toList());
     if (!updated.isEmpty()) {
       addMessage("");
       addMessage("Updated Changes:");
@@ -674,10 +757,10 @@
         String subject;
         if (edit) {
           try {
-            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
+            subject = receivePack.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
           } catch (IOException e) {
             // Log and fall back to original change subject
-            logWarn("failed to get subject for edit patch set", e);
+            logger.atWarning().withCause(e).log("failed to get subject for edit patch set");
             subject = u.notes.getChange().getSubject();
           }
         } else {
@@ -703,22 +786,16 @@
       }
       addMessage("");
     }
-
-    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
-    if (magicBranch != null && magicBranch.publish) {
-      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
-    }
   }
 
-  private void insertChangesAndPatchSets() {
+  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
     ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
     if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(
-          String.format(
-              "Skipping change updates on %s because ref update failed: %s %s",
-              project.getName(),
-              magicBranchCmd.getResult(),
-              Strings.nullToEmpty(magicBranchCmd.getMessage())));
+      logger.atWarning().log(
+          "Skipping change updates on %s because ref update failed: %s %s",
+          project.getName(),
+          magicBranchCmd.getResult(),
+          Strings.nullToEmpty(magicBranchCmd.getMessage()));
       return;
     }
 
@@ -729,26 +806,22 @@
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       bu.setRepository(repo, rw, ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
       bu.setRefLogMessage("push");
 
-      logDebug("Adding %d replace requests", newChanges.size());
+      logger.atFine().log("Adding %d replace requests", newChanges.size());
       for (ReplaceRequest replace : replaceByChange.values()) {
         replace.addOps(bu, replaceProgress);
       }
 
-      logDebug("Adding %d create requests", newChanges.size());
+      logger.atFine().log("Adding %d create requests", newChanges.size());
       for (CreateRequest create : newChanges) {
         create.addOps(bu);
       }
 
-      logDebug("Adding %d group update requests", newChanges.size());
+      logger.atFine().log("Adding %d group update requests", newChanges.size());
       updateGroups.forEach(r -> r.addOps(bu));
 
-      logDebug("Adding %d additional ref updates", actualCommands.size());
-      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
-
-      logDebug("Executing batch");
+      logger.atFine().log("Executing batch");
       try {
         bu.execute();
       } catch (UpdateException e) {
@@ -765,16 +838,17 @@
             replace.inputCommand.setResult(OK);
           }
         } else {
-          logDebug("Rejecting due to message from ReplaceOp");
+          logger.atFine().log("Rejecting due to message from ReplaceOp");
           reject(replace.inputCommand, rejectMessage);
         }
       }
 
     } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
+      addError(e.getMessage());
       reject(magicBranchCmd, "conflict");
     } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
+      logger.atSevere().withCause(err).log(
+          "Can't insert change/patch set for %s", project.getName());
       reject(magicBranchCmd, "internal server error: " + err.getMessage());
     }
 
@@ -782,7 +856,7 @@
       try {
         submit(newChanges, replaceByChange.values());
       } catch (ResourceConflictException e) {
-        addMessage(e.getMessage());
+        addError(e.getMessage());
         reject(magicBranchCmd, "conflict");
       } catch (RestApiException
           | OrmException
@@ -790,30 +864,26 @@
           | IOException
           | ConfigInvalidException
           | PermissionBackendException e) {
-        logError("Error submitting changes to " + project.getName(), e);
+        logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
         reject(magicBranchCmd, "error during submit");
       }
     }
   }
 
-  private String buildError(ReceiveError error, List<String> branches) {
+  private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
+      sb.append("branch ").append(branches.get(0)).append(":\n");
+      sb.append(error);
       return sb.toString();
     }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
+    sb.append("branches ").append(Joiner.on(", ").join(branches));
+    return sb.append(":\n").append(error).toString();
   }
 
+  /** Parses push options specified as "git push -o OPTION" */
   private void parsePushOptions() {
-    List<String> optionList = rp.getPushOptions();
+    List<String> optionList = receivePack.getPushOptions();
     if (optionList != null) {
       for (String option : optionList) {
         int e = option.indexOf('=');
@@ -828,7 +898,7 @@
     List<String> noteDbValues = pushOptions.get("notedb");
     if (!noteDbValues.isEmpty()) {
       // These semantics for duplicates/errors are somewhat arbitrary and may not match e.g. the
-      // CommandLineParser behavior used by MagicBranchInput.
+      // CmdLineParser behavior used by MagicBranchInput.
       String value = noteDbValues.get(noteDbValues.size() - 1);
       noteDbPushOption = NoteDbPushOption.parse(value);
       if (!noteDbPushOption.isPresent()) {
@@ -837,239 +907,266 @@
     } else {
       noteDbPushOption = Optional.of(NoteDbPushOption.DISALLOW);
     }
+
+    List<String> traceValues = pushOptions.get("trace");
+    if (!traceValues.isEmpty()) {
+      String value = traceValues.get(traceValues.size() - 1);
+      tracePushOption = Optional.of(value);
+    } else {
+      tracePushOption = Optional.empty();
+    }
   }
 
-  private void parseCommands(Collection<ReceiveCommand> commands)
+  private static boolean isDirectChangesPush(String refname) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(refname);
+    return m.matches();
+  }
+
+  private void parseDirectChangesPush(ReceiveCommand cmd) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
+    checkArgument(m.matches());
+
+    if (allowPushToRefsChanges) {
+      // The referenced change must exist and must still be open.
+      Change.Id changeId = Change.Id.parse(m.group(1));
+      parseReplaceCommand(cmd, changeId);
+      messages.add(new ValidationMessage("warning: pushes to refs/changes are deprecated", false));
+    } else {
+      reject(cmd, "upload to refs/changes not allowed");
+    }
+  }
+
+  // Wrap ReceiveCommand so the progress counter works automatically.
+  private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
+    String refname = cmd.getRefName();
+
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+      refname = RefNames.refsUsers(user.getAccountId());
+      logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
+    }
+
+    // We must also update the original, because callers may inspect it afterwards to decide if
+    // the command went through or not.
+    return new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), refname, cmd.getType()) {
+      @Override
+      public void setResult(Result s, String m) {
+        if (getResult() == NOT_ATTEMPTED) { // Only report the progress update once.
+          progress.update(1);
+        }
+        // Counter intuitively, we don't check that results == NOT_ATTEMPTED here.
+        // This is so submit-on-push can still reject the update if the change is created
+        // successfully
+        // (status OK) but the submit failed (merge failed: REJECTED_OTHER_REASON).
+        super.setResult(s, m);
+        cmd.setResult(s, m);
+      }
+    };
+  }
+
+  /*
+   * Interpret a normal push.
+   */
+  private void parseRegularCommand(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
-    logDebug("Parsing %d commands", commands.size());
-    for (ReceiveCommand cmd : commands) {
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        // Already rejected by the core receive process.
-        logDebug("Already processed by core: %s %s", cmd.getResult(), cmd);
-        continue;
-      }
+    if (cmd.getResult() != NOT_ATTEMPTED) {
+      // Already rejected by the core receive process.
+      logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
+      return;
+    }
 
-      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
-        reject(cmd, "not valid ref");
-        continue;
-      }
-
-      if (!projectState.getProject().getState().permitsWrite()) {
-        reject(cmd, "prohibited by Gerrit: project state does not permit write");
+    if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
+      reject(cmd, "not valid ref");
+      return;
+    }
+    if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
+      // Reject pushes to NoteDb refs without a special option and permission. Note that this
+      // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
+      // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
+      // migration finishes.
+      logger.atFine().log(
+          "%s NoteDb ref %s with %s=%s",
+          cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
+      if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
+        // Only reject this command, not the whole push. This supports the use case of "git clone
+        // --mirror" followed by "git push --mirror", when the user doesn't really intend to clone
+        // or mirror the NoteDb data; there is no single refspec that describes all refs *except*
+        // NoteDb refs.
+        reject(
+            cmd,
+            "NoteDb update requires -o "
+                + NoteDbPushOption.OPTION_NAME
+                + "="
+                + NoteDbPushOption.ALLOW.value());
         return;
       }
-
-      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-        parseMagicBranch(cmd);
-        continue;
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
+      } catch (AuthException e) {
+        reject(cmd, "NoteDb update requires access database permission");
+        return;
       }
+    }
 
-      if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
-        String newName = RefNames.refsUsers(user.getAccountId());
-        logDebug("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, newName);
-        final ReceiveCommand orgCmd = cmd;
-        cmd =
-            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
-              @Override
-              public void setResult(Result s, String m) {
-                super.setResult(s, m);
-                orgCmd.setResult(s, m);
-              }
-            };
-      }
+    switch (cmd.getType()) {
+      case CREATE:
+        parseCreate(cmd);
+        break;
 
-      Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
-      if (m.matches()) {
-        if (allowPushToRefsChanges) {
-          // The referenced change must exist and must still be open.
-          //
-          Change.Id changeId = Change.Id.parse(m.group(1));
-          parseReplaceCommand(cmd, changeId);
-        } else {
-          reject(cmd, "upload to refs/changes not allowed");
-        }
-        continue;
-      }
+      case UPDATE:
+        parseUpdate(cmd);
+        break;
 
-      if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
-        // Reject pushes to NoteDb refs without a special option and permission. Note that this
-        // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
-        // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
-        // migration finishes.
-        logDebug(
-            "%s NoteDb ref %s with %s=%s",
-            cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
-        if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
-          // Only reject this command, not the whole push. This supports the use case of "git clone
-          // --mirror" followed by "git push --mirror", when the user doesn't really intend to clone
-          // or mirror the NoteDb data; there is no single refspec that describes all refs *except*
-          // NoteDb refs.
-          reject(
-              cmd,
-              "NoteDb update requires -o "
-                  + NoteDbPushOption.OPTION_NAME
-                  + "="
-                  + NoteDbPushOption.ALLOW.value());
-          continue;
-        }
+      case DELETE:
+        parseDelete(cmd);
+        break;
+
+      case UPDATE_NONFASTFORWARD:
+        parseRewind(cmd);
+        break;
+
+      default:
+        reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+        return;
+    }
+
+    if (cmd.getResult() != NOT_ATTEMPTED) {
+      return;
+    }
+
+    if (isConfig(cmd)) {
+      validateConfigPush(cmd);
+    }
+  }
+
+  /** Validates a push to refs/meta/config, and reject the command if it fails. */
+  private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
+    logger.atFine().log("Processing %s command", cmd.getRefName());
+    try {
+      permissions.check(ProjectPermission.WRITE_CONFIG);
+    } catch (AuthException e) {
+      reject(
+          cmd,
+          String.format(
+              "must be either project owner or have %s permission",
+              ProjectPermission.WRITE_CONFIG.describeForException()));
+      return;
+    }
+
+    switch (cmd.getType()) {
+      case CREATE:
+      case UPDATE:
+      case UPDATE_NONFASTFORWARD:
         try {
-          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-        } catch (AuthException e) {
-          reject(cmd, "NoteDb update requires access database permission");
-          continue;
-        }
-      }
-
-      switch (cmd.getType()) {
-        case CREATE:
-          parseCreate(cmd);
-          break;
-
-        case UPDATE:
-          parseUpdate(cmd);
-          break;
-
-        case DELETE:
-          parseDelete(cmd);
-          break;
-
-        case UPDATE_NONFASTFORWARD:
-          parseRewind(cmd);
-          break;
-
-        default:
-          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
-          continue;
-      }
-
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        continue;
-      }
-
-      if (isConfig(cmd)) {
-        logDebug("Processing %s command", cmd.getRefName());
-        try {
-          permissions.check(ProjectPermission.WRITE_CONFIG);
-        } catch (AuthException e) {
-          reject(
-              cmd,
-              String.format(
-                  "must be either project owner or have %s permission",
-                  ProjectPermission.WRITE_CONFIG.describeForException()));
-          continue;
-        }
-
-        switch (cmd.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            try {
-              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(rp.getRevWalk(), cmd.getNewId());
-              if (!cfg.getValidationErrors().isEmpty()) {
-                addError("Invalid project configuration:");
-                for (ValidationError err : cfg.getValidationErrors()) {
-                  addError("  " + err.getMessage());
-                }
-                reject(cmd, "invalid project configuration");
-                logError(
-                    "User "
-                        + user.getLoggableName()
-                        + " tried to push invalid project configuration "
-                        + cmd.getNewId().name()
-                        + " for "
-                        + project.getName());
-                continue;
-              }
-              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-              Project.NameKey oldParent = project.getParent(allProjectsName);
-              if (oldParent == null) {
-                // update of the 'All-Projects' project
-                if (newParent != null) {
-                  reject(cmd, "invalid project configuration: root project cannot have parent");
-                  continue;
+          ProjectConfig cfg = new ProjectConfig(project.getNameKey());
+          cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
+          if (!cfg.getValidationErrors().isEmpty()) {
+            addError("Invalid project configuration:");
+            for (ValidationError err : cfg.getValidationErrors()) {
+              addError("  " + err.getMessage());
+            }
+            reject(cmd, "invalid project configuration");
+            logger.atSevere().log(
+                "User %s tried to push invalid project configuration %s for %s",
+                user.getLoggableName(), cmd.getNewId().name(), project.getName());
+            return;
+          }
+          Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+          Project.NameKey oldParent = project.getParent(allProjectsName);
+          if (oldParent == null) {
+            // update of the 'All-Projects' project
+            if (newParent != null) {
+              reject(cmd, "invalid project configuration: root project cannot have parent");
+              return;
+            }
+          } else {
+            if (!oldParent.equals(newParent)) {
+              if (allowProjectOwnersToChangeParent) {
+                try {
+                  permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .check(ProjectPermission.WRITE_CONFIG);
+                } catch (AuthException e) {
+                  reject(cmd, "invalid project configuration: only project owners can set parent");
+                  return;
                 }
               } else {
-                if (!oldParent.equals(newParent)) {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
-                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                    continue;
-                  }
-                }
-
-                if (projectCache.get(newParent) == null) {
-                  reject(cmd, "invalid project configuration: parent does not exist");
-                  continue;
+                try {
+                  permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                } catch (AuthException e) {
+                  reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                  return;
                 }
               }
+            }
 
-              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-                ProjectConfigEntry configEntry = e.getProvider().get();
-                String value = pluginCfg.getString(e.getExportName());
-                String oldValue =
+            if (projectCache.get(newParent) == null) {
+              reject(cmd, "invalid project configuration: parent does not exist");
+              return;
+            }
+          }
+          validatePluginConfig(cmd, cfg);
+        } catch (Exception e) {
+          reject(cmd, "invalid project configuration");
+          logger.atSevere().withCause(e).log(
+              "User %s tried to push invalid project configuration %s for %s",
+              user.getLoggableName(), cmd.getNewId().name(), project.getName());
+          return;
+        }
+        break;
+
+      case DELETE:
+        break;
+
+      default:
+        reject(
+            cmd,
+            "prohibited by Gerrit: don't know how to handle config update of type "
+                + cmd.getType());
+    }
+  }
+
+  /**
+   * validates a push to refs/meta/config for plugin configuration, and rejects the push if it
+   * fails.
+   */
+  private void validatePluginConfig(ReceiveCommand cmd, ProjectConfig cfg) {
+    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+      PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+      ProjectConfigEntry configEntry = e.getProvider().get();
+      String value = pluginCfg.getString(e.getExportName());
+      String oldValue =
+          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+      if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+        oldValue =
+            Arrays.stream(
                     projectState
                         .getConfig()
                         .getPluginConfig(e.getPluginName())
-                        .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  oldValue =
-                      Arrays.stream(
-                              projectState
-                                  .getConfig()
-                                  .getPluginConfig(e.getPluginName())
-                                  .getStringList(e.getExportName()))
-                          .collect(joining("\n"));
-                }
+                        .getStringList(e.getExportName()))
+                .collect(joining("\n"));
+      }
 
-                if ((value == null ? oldValue != null : !value.equals(oldValue))
-                    && !configEntry.isEditable(projectState)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: Not allowed to set parameter"
-                              + " '%s' of plugin '%s' on project '%s'.",
-                          e.getExportName(), e.getPluginName(), project.getName()));
-                  continue;
-                }
+      if ((value == null ? oldValue != null : !value.equals(oldValue))
+          && !configEntry.isEditable(projectState)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: Not allowed to set parameter"
+                    + " '%s' of plugin '%s' on project '%s'.",
+                e.getExportName(), e.getPluginName(), project.getName()));
+        continue;
+      }
 
-                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                    && value != null
-                    && !configEntry.getPermittedValues().contains(value)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: The value '%s' is "
-                              + "not permitted for parameter '%s' of plugin '%s'.",
-                          value, e.getExportName(), e.getPluginName()));
-                }
-              }
-            } catch (Exception e) {
-              reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getLoggableName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName(),
-                  e);
-              continue;
-            }
-            break;
-
-          case DELETE:
-            break;
-
-          default:
-            reject(
-                cmd,
-                "prohibited by Gerrit: don't know how to handle config update of type "
-                    + cmd.getType());
-            continue;
-        }
+      if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+          && value != null
+          && !configEntry.getPermittedValues().contains(value)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: The value '%s' is "
+                    + "not permitted for parameter '%s' of plugin '%s'.",
+                value, e.getExportName(), e.getPluginName()));
       }
     }
   }
@@ -1078,15 +1175,14 @@
       throws PermissionBackendException, NoSuchProjectException, IOException {
     RevObject obj;
     try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+      obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Creating %s", cmd);
+    logger.atFine().log("Creating %s", cmd);
 
     if (isHead(cmd) && !isCommit(cmd)) {
       return;
@@ -1096,55 +1192,43 @@
     try {
       // Must pass explicit user instead of injecting a provider into CreateRefControl, since
       // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
-      createRefControl.checkCreateRef(Providers.of(user), rp.getRepository(), branch, obj);
-    } catch (AuthException | ResourceConflictException denied) {
+      createRefControl.checkCreateRef(Providers.of(user), receivePack.getRepository(), branch, obj);
+    } catch (AuthException denied) {
+      rejectProhibited(cmd, denied);
+      return;
+    } catch (ResourceConflictException denied) {
       reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
       return;
     }
 
-    if (!validRefOperation(cmd)) {
-      // validRefOperation sets messages, so no need to provide more feedback.
-      return;
+    if (validRefOperation(cmd)) {
+      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
     }
-
-    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-    actualCommands.add(cmd);
   }
 
   private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Updating %s", cmd);
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
+    logger.atFine().log("Updating %s", cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
+    if (!err.isPresent()) {
       if (isHead(cmd) && !isCommit(cmd)) {
+        reject(cmd, "head must point to commit");
         return;
       }
-      if (!validRefOperation(cmd)) {
-        return;
+      if (validRefOperation(cmd)) {
+        validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
       }
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      actualCommands.add(cmd);
     } else {
-      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
-      } else {
-        errors.put(ReceiveError.UPDATE, cmd.getRefName());
-      }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
+      rejectProhibited(cmd, err.get());
     }
   }
 
   private boolean isCommit(ReceiveCommand cmd) {
     RevObject obj;
     try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+      obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return false;
     }
@@ -1157,76 +1241,87 @@
   }
 
   private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Deleting %s", cmd);
+    logger.atFine().log("Deleting %s", cmd);
     if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
+      errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
       reject(cmd, "cannot delete changes");
-    } else if (canDelete(cmd)) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
-    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+    } else if (isConfigRef(cmd.getRefName())) {
+      errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
       reject(cmd, "cannot delete project configuration");
+    }
+
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
+
     } else {
-      errors.put(ReceiveError.DELETE, cmd.getRefName());
-      reject(cmd, "cannot delete references");
-    }
-  }
-
-  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    if (isConfigRef(cmd.getRefName())) {
-      // Never allow to delete the meta config branch.
-      return false;
-    }
-
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
-      return projectState.statePermitsWrite();
-    } catch (AuthException e) {
-      return false;
+      rejectProhibited(cmd, err.get());
     }
   }
 
   private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
     RevCommit newObject;
     try {
-      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
+      newObject = receivePack.getRevWalk().parseCommit(cmd.getNewId());
     } catch (IncorrectObjectTypeException notCommit) {
       newObject = null;
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Rewinding %s", cmd);
+    logger.atFine().log("Rewinding %s", cmd);
 
     if (newObject != null) {
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
       if (cmd.getResult() != NOT_ATTEMPTED) {
         return;
       }
     }
 
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
     } else {
-      cmd.setResult(REJECTED_OTHER_REASON, "need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+      rejectProhibited(cmd, err.get());
     }
   }
 
+  private Optional<AuthException> checkRefPermission(ReceiveCommand cmd, RefPermission perm)
+      throws PermissionBackendException {
+    return checkRefPermission(permissions.ref(cmd.getRefName()), perm);
+  }
+
+  private Optional<AuthException> checkRefPermission(
+      PermissionBackend.ForRef forRef, RefPermission perm) throws PermissionBackendException {
+    try {
+      forRef.check(perm);
+      return Optional.empty();
+    } catch (AuthException e) {
+      return Optional.of(e);
+    }
+  }
+
+  private void rejectProhibited(ReceiveCommand cmd, AuthException err) {
+    err.getAdvice().ifPresent(a -> errors.put(a, cmd.getRefName()));
+    reject(cmd, prohibited(err, cmd.getRefName()));
+  }
+
+  private static String prohibited(AuthException e, String alreadyDisplayedResource) {
+    String msg = e.getMessage();
+    if (e instanceof PermissionDeniedException) {
+      PermissionDeniedException pde = (PermissionDeniedException) e;
+      if (pde.getResource().isPresent()
+          && pde.getResource().get().equals(alreadyDisplayedResource)) {
+        // Avoid repeating resource name if exactly the given name was already displayed by the
+        // generic git push machinery.
+        msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission();
+      }
+    }
+    return "prohibited by Gerrit: " + msg;
+  }
+
   static class MagicBranchInput {
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
@@ -1241,9 +1336,12 @@
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
-    CmdLineParser clp;
+    CmdLineParser cmdLineParser;
     Set<String> hashtags = new HashSet<>();
 
+    @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing")
+    String trace;
+
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
@@ -1337,7 +1435,7 @@
         LabelType.checkName(v.label());
         ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
       } catch (BadRequestException e) {
-        throw clp.reject(e.getMessage());
+        throw cmdLineParser.reject(e.getMessage());
       }
       labels.put(v.label(), v.value());
     }
@@ -1370,7 +1468,7 @@
         usage = "add hashtag to changes")
     void addHashtag(String token) throws CmdLineException {
       if (!notesMigration.readChanges()) {
-        throw clp.reject("cannot add hashtags; noteDb is disabled");
+        throw cmdLineParser.reject("cannot add hashtags; noteDb is disabled");
       }
       String hashtag = cleanupHashtag(token);
       if (!hashtag.isEmpty()) {
@@ -1418,15 +1516,16 @@
       return defaultPublishComments;
     }
 
-    String parse(
-        CmdLineParser clp,
-        Repository repo,
-        Set<String> refs,
-        ListMultimap<String, String> pushOptions)
+    /**
+     * returns the destination ref of the magic branch, and populates options in the cmdLineParser.
+     */
+    String parse(Repository repo, Set<String> refs, ListMultimap<String, String> pushOptions)
         throws CmdLineException {
       String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
 
       ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+
+      // Process and lop off the "%OPTION" suffix.
       int optionStart = ref.indexOf('%');
       if (0 < optionStart) {
         for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
@@ -1441,11 +1540,13 @@
       }
 
       if (!options.isEmpty()) {
-        clp.parseOptionMap(options);
+        cmdLineParser.parseOptionMap(options);
       }
 
-      // Split the destination branch by branch and topic. The topic
-      // suffix is entirely optional, so it might not even exist.
+      // We accept refs/for/BRANCHNAME/TOPIC. Since we don't know
+      // for sure where the branch ends and the topic starts, look
+      // backward for a split that works. This behavior has not been
+      // documented and should probably be deprecated.
       String head = readHEAD(repo);
       int split = ref.length();
       for (; ; ) {
@@ -1487,66 +1588,52 @@
   }
 
   /**
-   * Gets an unmodifiable view of the pushOptions.
+   * Parse the magic branch data (refs/for/BRANCH/OPTIONALTOPIC%OPTIONS) into the magicBranch
+   * member.
    *
-   * <p>The collection is empty if the client does not support push options, or if the client did
-   * not send any options.
-   *
-   * @return an unmodifiable view of pushOptions.
+   * <p>Assumes we are handling a magic branch here.
    */
-  @Nullable
-  ListMultimap<String, String> getPushOptions() {
-    return ImmutableListMultimap.copyOf(pushOptions);
-  }
-
   private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
-
-    logDebug("Found magic branch %s", cmd.getRefName());
-    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
+    logger.atFine().log("Found magic branch %s", cmd.getRefName());
+    MagicBranchInput magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
     magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
     magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
 
     String ref;
-    CmdLineParser clp = optionParserFactory.create(magicBranch);
-    magicBranch.clp = clp;
+    magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
     try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
+      ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
     } catch (CmdLineException e) {
-      if (!clp.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
+      if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
+        logger.atFine().log("Invalid branch syntax");
         reject(cmd, e.getMessage());
         return;
       }
-      ref = null; // never happen
+      ref = null; // never happens
     }
 
     if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
       reject(
-          cmd, String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
+          cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
-    if (clp.wasHelpRequestedByOption()) {
+    if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
       StringWriter w = new StringWriter();
       w.write("\nHelp for refs/for/branch:\n\n");
-      clp.printUsage(w, null);
+      magicBranch.cmdLineParser.printUsage(w, null);
       addMessage(w.toString());
       reject(cmd, "see help");
       return;
     }
     if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling %s", RefNames.REFS_USERS_SELF);
+      logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
       ref = RefNames.refsUsers(user.getAccountId());
     }
-    if (!rp.getAdvertisedRefs().containsKey(ref)
+    if (!receivePack.getAdvertisedRefs().containsKey(ref)
         && !ref.equals(readHEAD(repo))
         && !ref.equals(RefNames.REFS_CONFIG)) {
-      logDebug("Ref %s not found", ref);
+      logger.atFine().log("Ref %s not found", ref);
       if (ref.startsWith(Constants.R_HEADS)) {
         String n = ref.substring(Constants.R_HEADS.length());
         reject(cmd, "branch " + n + " not found");
@@ -1559,22 +1646,16 @@
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.perm = permissions.ref(ref);
 
-    try {
-      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
-    } catch (AuthException denied) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
-      reject(cmd, denied.getMessage());
-      return;
-    }
-    if (!projectState.statePermitsWrite()) {
-      reject(cmd, "project state does not permit write");
+    Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+    if (err.isPresent()) {
+      rejectProhibited(cmd, err.get());
       return;
     }
 
     // TODO(davido): Remove legacy support for drafts magic branch option
     // after repo-tool supports private and work-in-progress changes.
     if (magicBranch.draft && !receiveConfig.allowDrafts) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
+      errors.put(CODE_REVIEW_ERROR, ref);
       reject(cmd, "draft workflow is disabled");
       return;
     }
@@ -1607,22 +1688,21 @@
     }
 
     if (magicBranch.submit) {
-      try {
-        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
-      } catch (AuthException e) {
-        reject(cmd, e.getMessage());
+      err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
         return;
       }
     }
 
-    RevWalk walk = rp.getRevWalk();
+    RevWalk walk = receivePack.getRevWalk();
     RevCommit tip;
     try {
       tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("Tip of push: %s", tip.name());
+      logger.atFine().log("Tip of push: %s", tip.name());
     } catch (IOException ex) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", ex);
+      logger.atSevere().withCause(ex).log("Invalid pack upload; one or more objects weren't sent");
       return;
     }
 
@@ -1649,12 +1729,12 @@
           || magicBranch.base != null
           || magicBranch.merged
           || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
+        logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
         newChangeForAllNotInTarget = false;
       }
 
       if (magicBranch.base != null) {
-        logDebug("Handling %base: %s", magicBranch.base);
+        logger.atFine().log("Handling %%base: %s", magicBranch.base);
         magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
         for (ObjectId id : magicBranch.base) {
           try {
@@ -1666,7 +1746,8 @@
             reject(cmd, "base not found");
             return;
           } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            logger.atWarning().withCause(e).log(
+                "Project %s cannot read %s", project.getName(), id.name());
             reject(cmd, "internal server error");
             return;
           }
@@ -1677,31 +1758,40 @@
           return; // readBranchTip already rejected cmd.
         }
         magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
+        logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
       }
     } catch (IOException ex) {
-      logWarn(
-          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      logger.atWarning().withCause(ex).log(
+          "Error walking to %s in project %s", destBranch, project.getName());
       reject(cmd, "internal server error");
       return;
     }
 
-    // Validate that the new commits are connected with the target
-    // branch.  If they aren't, we want to abort. We do this check by
-    // looking to see if we can compute a merge base between the new
-    // commits and the target branch head.
-    //
+    if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
+      this.magicBranch = magicBranch;
+    }
+  }
+
+  // Validate that the new commits are connected with the target
+  // branch.  If they aren't, we want to abort. We do this check by
+  // looking to see if we can compute a merge base between the new
+  // commits and the target branch head.
+  private boolean validateConnected(ReceiveCommand cmd, Branch.NameKey dest, RevCommit tip) {
+    RevWalk walk = receivePack.getRevWalk();
     try {
-      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.dest.get());
+      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.get());
       if (targetRef == null || targetRef.getObjectId() == null) {
         // The destination branch does not yet exist. Assume the
         // history being sent for review will start it and thus
         // is "connected" to the branch.
-        logDebug("Branch is unborn");
-        return;
+        logger.atFine().log("Branch is unborn");
+
+        // This is not an error condition.
+        return true;
       }
+
       RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: %s", h.name());
+      logger.atFine().log("Current branch tip: %s", h.name());
       RevFilter oldRevFilter = walk.getRevFilter();
       try {
         walk.reset();
@@ -1709,16 +1799,19 @@
         walk.markStart(tip);
         walk.markStart(h);
         if (walk.next() == null) {
-          reject(magicBranch.cmd, "no common ancestry");
+          reject(cmd, "no common ancestry");
+          return false;
         }
       } finally {
         walk.reset();
         walk.setRevFilter(oldRevFilter);
       }
     } catch (IOException e) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+      cmd.setResult(REJECTED_MISSING_OBJECT);
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+      return false;
     }
+    return true;
   }
 
   private static String readHEAD(Repository repo) {
@@ -1736,11 +1829,12 @@
       reject(cmd, branch.get() + " not found");
       return null;
     }
-    return rp.getRevWalk().parseCommit(r.getObjectId());
+    return receivePack.getRevWalk().parseCommit(r.getObjectId());
   }
 
+  // Handle an upload to refs/changes/XX/CHANGED-NUMBER.
   private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
+    logger.atFine().log("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
       reject(cmd, "invalid usage");
       return;
@@ -1748,10 +1842,10 @@
 
     RevCommit newCommit;
     try {
-      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with %s", newCommit);
+      newCommit = receivePack.getRevWalk().parseCommit(cmd.getNewId());
+      logger.atFine().log("Replacing with %s", newCommit);
     } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      logger.atSevere().withCause(e).log("Cannot parse %s as commit", cmd.getNewId().name());
       reject(cmd, "invalid commit");
       return;
     }
@@ -1760,11 +1854,11 @@
     try {
       changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
     } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
+      logger.atSevere().withCause(e).log("Change not found %s", changeId);
       reject(cmd, "change " + changeId + " not found");
       return;
     } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
+      logger.atSevere().withCause(e).log("Cannot lookup existing change %s", changeId);
       reject(cmd, "database error");
       return;
     }
@@ -1773,10 +1867,29 @@
       return;
     }
 
-    logDebug("Replacing change %s", changeEnt.getId());
-    requestReplace(cmd, true, changeEnt, newCommit);
+    BranchCommitValidator validator =
+        commitValidatorFactory.create(projectState, changeEnt.getDest(), user);
+    try {
+      if (validator.validCommit(
+          receivePack.getRevWalk().getObjectReader(),
+          cmd,
+          newCommit,
+          false,
+          messages,
+          rejectCommits,
+          changeEnt)) {
+        logger.atFine().log("Replacing change %s", changeEnt.getId());
+        requestReplace(cmd, true, changeEnt, newCommit);
+      }
+    } catch (IOException e) {
+      reject(cmd, "I/O exception validating commit");
+    }
   }
 
+  /**
+   * Add an update for an existing change. Returns true if it succeeded; rejects the command if it
+   * failed.
+   */
   private boolean requestReplace(
       ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
     if (change.getStatus().isClosed()) {
@@ -1796,18 +1909,38 @@
     return true;
   }
 
-  private void selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
-    newChanges = new ArrayList<>();
+  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+    for (CreateRequest create : newChanges) {
+      try {
+        receivePack.getRevWalk().parseBody(create.commit);
+      } catch (IOException e) {
+        continue;
+      }
+      List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
+
+      if (idList.isEmpty()) {
+        messages.add(
+            new ValidationMessage("warning: pushing without Change-Id is deprecated", false));
+        break;
+      }
+    }
+  }
+
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+    logger.atFine().log("Finding new and replaced changes");
+    List<CreateRequest> newChanges = new ArrayList<>();
 
     ListMultimap<ObjectId, Ref> existing = changeRefsById();
     GroupCollector groupCollector =
         GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
 
+    BranchCommitValidator validator =
+        commitValidatorFactory.create(projectState, magicBranch.dest, user);
+
     try {
       RevCommit start = setUpWalkForSelectingChanges();
       if (start == null) {
-        return;
+        return Collections.emptyList();
       }
 
       LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
@@ -1832,12 +1965,12 @@
       }
 
       for (; ; ) {
-        RevCommit c = rp.getRevWalk().next();
+        RevCommit c = receivePack.getRevWalk().next();
         if (c == null) {
           break;
         }
         total++;
-        rp.getRevWalk().parseBody(c);
+        receivePack.getRevWalk().parseBody(c);
         String name = c.name();
         groupCollector.visit(c);
         Collection<Ref> existingRefs = existing.get(c);
@@ -1872,22 +2005,20 @@
         }
 
         List<String> idList = c.getFooterLines(CHANGE_ID);
-
-        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
-
-        if (idStr != null) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
+        if (!idList.isEmpty()) {
+          pending.put(
+              c, lookupByChangeKey(c, new Change.Key(idList.get(idList.size() - 1).trim())));
         } else {
-          pending.put(c, new ChangeLookup(c));
+          pending.put(c, lookupByCommit(c));
         }
+
         int n = pending.size() + newChanges.size();
         if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("%d changes exceeds limit of %d", n, maxBatchChanges);
+          logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
           reject(
               magicBranch.cmd,
               "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-          newChanges = Collections.emptyList();
-          return;
+          return Collections.emptyList();
         }
 
         if (commitAlreadyTracked) {
@@ -1902,15 +2033,20 @@
             continue;
           }
 
-          logDebug("Creating new change for %s even though it is already tracked", name);
+          logger.atFine().log("Creating new change for %s even though it is already tracked", name);
         }
 
-        if (!validCommit(
-            rp.getRevWalk(), magicBranch.perm, magicBranch.dest, magicBranch.cmd, c, null)) {
+        if (!validator.validCommit(
+            receivePack.getRevWalk().getObjectReader(),
+            magicBranch.cmd,
+            c,
+            magicBranch.merged,
+            messages,
+            rejectCommits,
+            null)) {
           // Not a change the user can propose? Abort as early as possible.
-          newChanges = Collections.emptyList();
-          logDebug("Aborting early due to invalid commit");
-          return;
+          logger.atFine().log("Aborting early due to invalid commit");
+          return Collections.emptyList();
         }
 
         // Don't allow merges to be uploaded in commit chain via all-not-in-target
@@ -1919,16 +2055,16 @@
               magicBranch.cmd,
               "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
                   + "to override please set the base manually");
-          logDebug("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
+          logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
           // TODO(dborowitz): Should we early return here?
         }
 
         if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get(), newProgress));
           continue;
         }
       }
-      logDebug(
+      logger.atFine().log(
           "Finished initial RevWalk with %d commits total: %d already"
               + " tracked, %d new changes with no Change-Id, and %d deferred"
               + " lookups",
@@ -1945,15 +2081,14 @@
         }
 
         if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id %s", p.changeKey);
+          logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
           reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          newChanges = Collections.emptyList();
-          return;
+          return Collections.emptyList();
         }
 
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
-          logDebug(
+          logger.atFine().log(
               "Multiple changes in branch %s with Change-Id %s: %s",
               magicBranch.dest,
               p.changeKey,
@@ -1964,8 +2099,7 @@
           // this error message as Change-Id should be unique per branch.
           //
           reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-          newChanges = Collections.emptyList();
-          return;
+          return Collections.emptyList();
         }
 
         if (changes.size() == 1) {
@@ -1989,15 +2123,13 @@
           if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
             continue;
           }
-          newChanges = Collections.emptyList();
-          return;
+          return Collections.emptyList();
         }
 
         if (changes.size() == 0) {
           if (!isValidChangeId(p.changeKey.get())) {
             reject(magicBranch.cmd, "invalid Change-Id");
-            newChanges = Collections.emptyList();
-            return;
+            return Collections.emptyList();
           }
 
           // In case the change look up from the index failed,
@@ -2005,17 +2137,16 @@
           if (foundInExistingRef(existing.get(p.commit))) {
             if (pending.size() == 1) {
               reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-              newChanges = Collections.emptyList();
-              return;
+              return Collections.emptyList();
             }
             itr.remove();
             continue;
           }
           newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get(), newProgress));
       }
-      logDebug(
+      logger.atFine().log(
           "Finished deferred lookups with %d updates and %d new changes",
           replaceByChange.size(), newChanges.size());
     } catch (IOException e) {
@@ -2023,23 +2154,21 @@
       // identified the missing object earlier before we got control.
       //
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-      newChanges = Collections.emptyList();
-      return;
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+      return Collections.emptyList();
     } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
+      logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
       reject(magicBranch.cmd, "database error");
-      newChanges = Collections.emptyList();
-      return;
+      return Collections.emptyList();
     }
 
     if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
       reject(magicBranch.cmd, "no new changes");
-      return;
+      return Collections.emptyList();
     }
     if (!newChanges.isEmpty() && magicBranch.edit) {
       reject(magicBranch.cmd, "edit is not supported for new changes");
-      return;
+      return newChanges;
     }
 
     try {
@@ -2056,12 +2185,12 @@
       for (UpdateGroupsRequest update : updateGroups) {
         update.groups = ImmutableList.copyOf((groups.get(update.commit)));
       }
-      logDebug("Finished updating groups from GroupCollector");
+      logger.atFine().log("Finished updating groups from GroupCollector");
     } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
+      logger.atSevere().withCause(e).log("Error collecting groups for changes");
       reject(magicBranch.cmd, "internal server error");
-      return;
     }
+    return newChanges;
   }
 
   private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
@@ -2070,7 +2199,7 @@
           notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
       Change change = notes.getChange();
       if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change %s from existing refs.", change.getKey());
+        logger.atFine().log("Found change %s from existing refs.", change.getKey());
         // Reindex the change asynchronously, ignoring errors.
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
@@ -2081,17 +2210,17 @@
   }
 
   private RevCommit setUpWalkForSelectingChanges() throws IOException {
-    RevWalk rw = rp.getRevWalk();
+    RevWalk rw = receivePack.getRevWalk();
     RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
 
     rw.reset();
     rw.sort(RevSort.TOPO);
     rw.sort(RevSort.REVERSE, true);
-    rp.getRevWalk().markStart(start);
+    receivePack.getRevWalk().markStart(start);
     if (magicBranch.baseCommit != null) {
       markExplicitBasesUninteresting();
     } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit %s uninteresting", start.name());
+      logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
       for (RevCommit c : start.getParents()) {
         rw.markUninteresting(c);
       }
@@ -2102,16 +2231,18 @@
   }
 
   private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
+    logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
     for (RevCommit c : magicBranch.baseCommit) {
-      rp.getRevWalk().markUninteresting(c);
+      receivePack.getRevWalk().markUninteresting(c);
     }
     Ref targetRef = allRefs().get(magicBranch.dest.get());
     if (targetRef != null) {
-      logDebug(
+      logger.atFine().log(
           "Marking target ref %s (%s) uninteresting",
           magicBranch.dest.get(), targetRef.getObjectId().name());
-      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+      receivePack
+          .getRevWalk()
+          .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
     }
   }
 
@@ -2119,7 +2250,7 @@
     if (!mergedParents.isEmpty()) {
       Ref targetRef = allRefs().get(magicBranch.dest.get());
       if (targetRef != null) {
-        RevWalk rw = rp.getRevWalk();
+        RevWalk rw = receivePack.getRevWalk();
         RevCommit tip = rw.parseCommit(targetRef.getObjectId());
         boolean containsImplicitMerges = true;
         for (RevCommit p : mergedParents) {
@@ -2149,6 +2280,8 @@
     }
   }
 
+  // Mark all branch tips as uninteresting in the given revwalk,
+  // so we get only the new commits when walking rw.
   private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
     int i = 0;
     for (Ref ref : allRefs().values()) {
@@ -2158,38 +2291,45 @@
           rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
           i++;
         } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+          logger.atWarning().withCause(e).log(
+              "Invalid ref %s in %s", ref.getName(), project.getName());
         }
       }
     }
-    logDebug("Marked %d heads as uninteresting", i);
+    logger.atFine().log("Marked %d heads as uninteresting", i);
   }
 
   private static boolean isValidChangeId(String idStr) {
     return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
   }
 
-  private class ChangeLookup {
+  private static class ChangeLookup {
     final RevCommit commit;
-    final Change.Key changeKey;
+
+    @Nullable final Change.Key changeKey;
     final List<ChangeData> destChanges;
 
-    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
-      commit = c;
-      changeKey = key;
-      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
-    }
-
-    ChangeLookup(RevCommit c) throws OrmException {
-      commit = c;
-      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
-      changeKey = null;
+    ChangeLookup(RevCommit c, @Nullable Change.Key key, final List<ChangeData> destChanges) {
+      this.commit = c;
+      this.changeKey = key;
+      this.destChanges = destChanges;
     }
   }
 
+  ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) throws OrmException {
+    return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+  }
+
+  ChangeLookup lookupByCommit(RevCommit c) throws OrmException {
+    return new ChangeLookup(
+        c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+  }
+
+  /** Represents a commit for which a Change should be created. */
   private class CreateRequest {
     final RevCommit commit;
-    private final String refName;
+    final Task progress;
+    final String refName;
 
     Change.Id changeId;
     ReceiveCommand cmd;
@@ -2198,12 +2338,14 @@
 
     Change change;
 
-    CreateRequest(RevCommit commit, String refName) {
+    CreateRequest(RevCommit commit, String refName, Task progress) {
       this.commit = commit;
       this.refName = refName;
+      this.progress = progress;
     }
 
     private void setChangeId(int id) {
+      possiblyOverrideWorkInProgress();
 
       changeId = new Change.Id(id);
       ins =
@@ -2219,30 +2361,39 @@
         ins.setStatus(Change.Status.MERGED);
       }
       cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
-      if (rp.getPushCertificate() != null) {
-        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
+      if (receivePack.getPushCertificate() != null) {
+        ins.setPushCertificate(receivePack.getPushCertificate().toTextWithSignature());
       }
     }
 
+    private void possiblyOverrideWorkInProgress() {
+      // When wip or ready explicitly provided, leave it as is.
+      if (magicBranch.workInProgress || magicBranch.ready) {
+        return;
+      }
+      magicBranch.workInProgress =
+          projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)
+              || firstNonNull(user.state().getGeneralPreferences().workInProgressByDefault, false);
+    }
+
     private void addOps(BatchUpdate bu) throws RestApiException {
       checkState(changeId != null, "must call setChangeId before addOps");
       try {
-        RevWalk rw = rp.getRevWalk();
+        RevWalk rw = receivePack.getRevWalk();
         rw.parseBody(commit);
         final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
         Account.Id me = user.getAccountId();
         List<FooterLine> footerLines = commit.getFooterLines();
         MailRecipients recipients = new MailRecipients();
-        Map<String, Short> approvals = new HashMap<>();
         checkNotNull(magicBranch);
         recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
+        Map<String, Short> approvals = magicBranch.labels;
         recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
         recipients.remove(me);
         StringBuilder msg =
             new StringBuilder(
                 ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
+                    psId.get(), approvals, Collections.emptyMap()));
         msg.append('.');
         if (!Strings.isNullOrEmpty(magicBranch.message)) {
           msg.append("\n").append(magicBranch.message);
@@ -2284,7 +2435,7 @@
                 return false;
               }
             });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+        bu.addOp(changeId, new ChangeProgressOp(progress));
       } catch (Exception e) {
         throw INSERT_EXCEPTION.apply(e);
       }
@@ -2305,47 +2456,40 @@
     Change tipChange = bySha.get(magicBranch.cmd.getNewId());
     checkNotNull(
         tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
+    logger.atFine().log(
         "Processing submit with tip change %s (%s)", tipChange.getId(), magicBranch.cmd.getNewId());
     try (MergeOp op = mergeOpProvider.get()) {
       op.merge(db, tipChange, user, false, new SubmitInput(), false);
     }
   }
 
-  private void preparePatchSetsForReplace() {
+  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
     try {
       readChangesForReplace();
       for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
         ReplaceRequest req = itr.next();
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
-          if (req.skip && req.cmd == null) {
-            itr.remove();
-          }
+          req.validateNewPatchSet();
         }
       }
     } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read database before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     } catch (IOException | PermissionBackendException err) {
-      logError(
-          String.format(
-              "Cannot read repository before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read repository before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     }
-    logDebug("Read %d changes to replace", replaceByChange.size());
+    logger.atFine().log("Read %d changes to replace", replaceByChange.size());
 
     if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
       // Cancel creations tied to refs/for/ or refs/drafts/ command.
@@ -2369,6 +2513,7 @@
     }
   }
 
+  /** Represents a commit that should be stored in a new patchset of an existing change. */
   private class ReplaceRequest {
     final Change.Id ontoChange;
     final ObjectId newCommitId;
@@ -2380,10 +2525,9 @@
     ReceiveCommand prev;
     ReceiveCommand cmd;
     PatchSetInfo info;
-    boolean skip;
-    private PatchSet.Id priorPatchSet;
+    PatchSet.Id priorPatchSet;
     List<String> groups = ImmutableList.of();
-    private ReplaceOp replaceOp;
+    ReplaceOp replaceOp;
 
     ReplaceRequest(
         Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
@@ -2396,12 +2540,11 @@
       for (Ref ref : refs(toChange)) {
         try {
           revisions.forcePut(
-              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
+              receivePack.getRevWalk().parseCommit(ref.getObjectId()),
+              PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
+          logger.atWarning().withCause(err).log(
+              "Project %s contains invalid change ref %s", project.getName(), ref.getName());
         }
       }
     }
@@ -2414,21 +2557,49 @@
      * <ul>
      *   <li>May add error or warning messages to the progress monitor
      *   <li>Will reject {@code cmd} prior to returning false
-     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
+     *   <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk.
      * </ul>
      *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
      * @return whether the new commit is valid
      * @throws IOException
      * @throws OrmException
      * @throws PermissionBackendException
      */
-    boolean validate(boolean autoClose)
-        throws IOException, OrmException, PermissionBackendException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+    boolean validateNewPatchSet() throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb()) {
         return false;
-      } else if (notes == null) {
+      }
+      sameTreeWarning();
+
+      if (magicBranch != null) {
+        validateMagicBranchWipStatusChange();
+        if (inputCommand.getResult() != NOT_ATTEMPTED) {
+          return false;
+        }
+
+        if (magicBranch.edit || magicBranch.draft) {
+          return newEdit();
+        }
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    boolean validateNewPatchSetForAutoClose()
+        throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb()) {
+        return false;
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    /** Validates the new PS against permissions and notedb status. */
+    private boolean validateNewPatchSetNoteDb()
+        throws IOException, OrmException, PermissionBackendException {
+      if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
@@ -2440,11 +2611,10 @@
         return false;
       }
 
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
 
       // Not allowed to create a new patch set if the current patch set is locked.
-      if (psUtil.isPatchSetLocked(notes, user)) {
+      if (psUtil.isPatchSetLocked(notes)) {
         reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
         return false;
       }
@@ -2456,10 +2626,6 @@
         return false;
       }
 
-      if (!projectState.statePermitsWrite()) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
       if (change.getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
@@ -2468,7 +2634,7 @@
         return false;
       }
 
-      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
+      for (Ref r : receivePack.getRepository().getRefDatabase().getRefsByPrefix("refs/changes")) {
         if (r.getObjectId().equals(newCommit)) {
           reject(inputCommand, "commit already exists (in the project)");
           return false;
@@ -2479,37 +2645,59 @@
         // Don't allow a change to directly depend upon itself. This is a
         // very common error due to users making a new commit rather than
         // amending when trying to address review comments.
-        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
+        if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) {
           reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           return false;
         }
       }
 
-      PermissionBackend.ForRef perm = permissions.ref(change.getDest().get());
-      if (!validCommit(rp.getRevWalk(), perm, change.getDest(), inputCommand, newCommit, change)) {
-        return false;
-      }
-      rp.getRevWalk().parseBody(priorCommit);
+      return true;
+    }
 
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
+    /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */
+    private void validateMagicBranchWipStatusChange() throws PermissionBackendException {
+      Change change = notes.getChange();
+      if ((magicBranch.workInProgress || magicBranch.ready)
+          && magicBranch.workInProgress != change.isWorkInProgress()
+          && !user.getAccountId().equals(change.getOwner())) {
+        boolean hasWriteConfigPermission = false;
+        try {
+          permissions.check(ProjectPermission.WRITE_CONFIG);
+          hasWriteConfigPermission = true;
+        } catch (AuthException e) {
+          // Do nothing.
+        }
+
+        if (!hasWriteConfigPermission) {
+          try {
+            permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+          } catch (AuthException e1) {
+            reject(inputCommand, ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
+          }
+        }
+      }
+    }
+
+    /** prints a warning if the new PS has the same tree as the previous commit. */
+    private void sameTreeWarning() throws IOException {
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+
       if (newCommit.getTree().equals(priorCommit.getTree())) {
         boolean messageEq =
             Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
         boolean parentsEq = parentsEqual(newCommit, priorCommit);
         boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = rp.getRevWalk().getObjectReader();
+        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
 
-        if (messageEq && parentsEq && authorEq && !autoClose) {
+        if (messageEq && parentsEq && authorEq) {
           addMessage(
               String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
+                  "warning: no changes between prior commit %s and new commit %s",
                   reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
         } else {
           StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
+          msg.append("warning: ").append(reader.abbreviate(newCommit).name());
           msg.append(":");
           msg.append(" no files changed");
           if (!authorEq) {
@@ -2524,31 +2712,20 @@
           addMessage(msg.toString());
         }
       }
-
-      if (magicBranch != null
-          && (magicBranch.workInProgress || magicBranch.ready)
-          && magicBranch.workInProgress != change.isWorkInProgress()
-          && !user.getAccountId().equals(change.getOwner())) {
-        reject(inputCommand, ONLY_OWNER_CAN_MODIFY_WIP);
-        return false;
-      }
-
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        return newEdit();
-      }
-
-      newPatchSet();
-      return true;
     }
 
+    /**
+     * Sets cmd and prev to the ReceiveCommands for change edits. Returns false if there was a
+     * failure.
+     */
     private boolean newEdit() {
       psId = notes.getChange().currentPatchSetId();
-      Optional<ChangeEdit> edit = null;
+      Optional<ChangeEdit> edit;
 
       try {
         edit = editUtil.byChange(notes, user);
       } catch (AuthException | IOException e) {
-        logError("Cannot retrieve edit", e);
+        logger.atSevere().withCause(e).log("Cannot retrieve edit");
         return false;
       }
 
@@ -2571,8 +2748,8 @@
       return true;
     }
 
+    /** Creates a ReceiveCommand for a new edit. */
     private void createEditCommand() {
-      // create new edit
       cmd =
           new ReceiveCommand(
               ObjectId.zeroId(),
@@ -2580,11 +2757,12 @@
               RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
     }
 
+    /** Updates 'this' to add a new patchset. */
     private void newPatchSet() throws IOException, OrmException {
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
       psId =
           ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
+      info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
     }
 
@@ -2597,7 +2775,7 @@
         bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
         return;
       }
-      RevWalk rw = rp.getRevWalk();
+      RevWalk rw = receivePack.getRevWalk();
       // TODO(dborowitz): Move to ReplaceOp#updateRepo.
       RevCommit newCommit = rw.parseCommit(newCommitId);
       rw.parseBody(newCommit);
@@ -2616,7 +2794,7 @@
                   info,
                   groups,
                   magicBranch,
-                  rp.getPushCertificate())
+                  receivePack.getPushCertificate())
               .setRequestScopePropagator(requestScopePropagator);
       bu.addOp(notes.getChangeId(), replaceOp);
       if (progress != null) {
@@ -2630,8 +2808,8 @@
   }
 
   private class UpdateGroupsRequest {
-    private final PatchSet.Id psId;
-    private final RevCommit commit;
+    final PatchSet.Id psId;
+    final RevCommit commit;
     List<String> groups = ImmutableList.of();
 
     UpdateGroupsRequest(Ref ref, RevCommit commit) {
@@ -2666,7 +2844,7 @@
   }
 
   private class UpdateOneRefOp implements RepoOnlyOp {
-    private final ReceiveCommand cmd;
+    final ReceiveCommand cmd;
 
     private UpdateOneRefOp(ReceiveCommand cmd) {
       this.cmd = checkNotNull(cmd);
@@ -2681,11 +2859,11 @@
     public void postUpdate(Context ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-        logDebug("Updating tag cache on fast-forward of %s", cmd.getRefName());
+        logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
         tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
       }
       if (isConfig(cmd)) {
-        logDebug("Reloading project in cache");
+        logger.atFine().log("Reloading project in cache");
         try {
           projectCache.evict(project);
         } catch (IOException e) {
@@ -2694,7 +2872,7 @@
         }
         ProjectState ps = projectCache.get(project.getNameKey());
         try {
-          logDebug("Updating project description");
+          logger.atFine().log("Updating project description");
           repo.setGitwebDescription(ps.getProject().getDescription());
         } catch (IOException e) {
           logger.atWarning().withCause(e).log("cannot update description of %s", project.getName());
@@ -2779,6 +2957,8 @@
         && Objects.equals(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
   }
 
+  // Run RefValidators on the command. If any validator fails, the command status is set to
+  // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
@@ -2793,32 +2973,37 @@
     return true;
   }
 
-  private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
+  /**
+   * Validates the commits that a regular push brings in.
+   *
+   * <p>On validation failure, the command is rejected.
+   */
+  private void validateRegularPushCommits(Branch.NameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
     if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
             || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
         && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
-      try {
-        if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
-          throw new AuthException(
-              "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-
-        perm.check(RefPermission.SKIP_VALIDATION);
-        if (!Iterables.isEmpty(rejectCommits)) {
-          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-        logDebug("Short-circuiting new commit validation");
-      } catch (AuthException denied) {
-        reject(cmd, denied.getMessage());
+      if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+        reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+        return;
       }
+
+      Optional<AuthException> err =
+          checkRefPermission(permissions.ref(branch.get()), RefPermission.SKIP_VALIDATION);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
+        return;
+      }
+      if (!Iterables.isEmpty(rejectCommits)) {
+        reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+      }
+      logger.atFine().log("Short-circuiting new commit validation");
       return;
     }
 
-    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
-    RevWalk walk = rp.getRevWalk();
+    BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
+    RevWalk walk = receivePack.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
     try {
@@ -2833,82 +3018,35 @@
       int n = 0;
       for (RevCommit c; (c = walk.next()) != null; ) {
         if (++n > limit) {
-          logDebug("Number of new commits exceeds limit of %d", limit);
-          addMessage(
+          logger.atFine().log("Number of new commits exceeds limit of %d", limit);
+          reject(
+              cmd,
               String.format(
-                  "Cannot push more than %d commits to %s without %s option "
-                      + "(see %sDocumentation/user-upload.html#skip_validation for details)",
-                  limit, branch.get(), PUSH_OPTION_SKIP_VALIDATION, canonicalWebUrl));
-          reject(cmd, "too many commits");
+                  "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
           return;
         }
         if (existing.keySet().contains(c)) {
           continue;
-        } else if (!validCommit(walk, perm, branch, cmd, c, null)) {
+        }
+
+        if (!validator.validCommit(
+            walk.getObjectReader(), cmd, c, false, messages, rejectCommits, null)) {
           break;
         }
-
-        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          logDebug("Will update full name of caller");
-          setFullNameTo = c.getCommitterIdent().getName();
-          missingFullName = false;
-        }
       }
-      logDebug("Validated %d new commits", n);
+      logger.atFine().log("Validated %d new commits", n);
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
+      logger.atSevere().withCause(err).log("Invalid pack upload; one or more objects weren't sent");
     }
   }
 
-  private boolean validCommit(
-      RevWalk rw,
-      PermissionBackend.ForRef perm,
-      Branch.NameKey branch,
-      ReceiveCommand cmd,
-      ObjectId id,
-      @Nullable Change change)
-      throws IOException {
-
-    if (validCommits.contains(id)) {
-      return true;
-    }
-
-    RevCommit c = rw.parseCommit(id);
-    rw.parseBody(c);
-
-    try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
-      boolean isMerged =
-          magicBranch != null
-              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-              && magicBranch.merged;
-      CommitValidators validators =
-          isMerged
-              ? commitValidatorsFactory.forMergedCommits(
-                  project.getNameKey(), perm, user.asIdentifiedUser())
-              : commitValidatorsFactory.forReceiveCommits(
-                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw, change);
-      messages.addAll(validators.validate(receiveEvent));
-    } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on %s", c.name());
-      messages.addAll(e.getMessages());
-      reject(cmd, e.getMessage());
-      return false;
-    }
-    validCommits.add(c.copy());
-    return true;
-  }
-
-  private void autoCloseChanges(ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
+  private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
+    logger.atFine().log("Starting auto-closing of changes");
     String refName = cmd.getRefName();
-    checkState(
-        !MagicBranch.isMagicBranch(refName),
-        "shouldn't be auto-closing changes on magic branch %s",
-        refName);
+
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // insertChangesAndPatchSets.
+    // handleRegularCommands
     try {
       retryHelper.execute(
           updateFactory -> {
@@ -2918,7 +3056,6 @@
                 ObjectReader reader = ins.newReader();
                 RevWalk rw = new RevWalk(reader)) {
               bu.setRepository(repo, rw, ins).updateChangesInParallel();
-              bu.setRequestId(receiveId);
               // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
               RevCommit newTip = rw.parseCommit(cmd.getNewId());
@@ -2942,9 +3079,8 @@
 
                 for (Ref ref : byCommit.get(c.copy())) {
                   PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-                  Optional<ChangeData> cd =
-                      executeIndexQuery(() -> byLegacyId(psId.getParentKey()));
-                  if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
+                  Optional<ChangeNotes> notes = getChangeNotes(psId.getParentKey());
+                  if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                     existingPatchSets++;
                     bu.addOp(
                         psId.getParentKey(),
@@ -2973,8 +3109,8 @@
 
               for (ReplaceRequest req : replaceAndClose) {
                 Change.Id id = req.notes.getChangeId();
-                if (!executeRequestValidation(() -> req.validate(true))) {
-                  logDebug("Not closing %s because validation failed", id);
+                if (!req.validateNewPatchSetForAutoClose()) {
+                  logger.atFine().log("Not closing %s because validation failed", id);
                   continue;
                 }
                 req.addOps(bu, null);
@@ -2983,15 +3119,15 @@
                     mergedByPushOpFactory
                         .create(requestScopePropagator, req.psId, refName)
                         .setPatchSetProvider(req.replaceOp::getPatchSet));
-                bu.addOp(id, new ChangeProgressOp(closeProgress));
+                bu.addOp(id, new ChangeProgressOp(progress));
               }
 
-              logDebug(
+              logger.atFine().log(
                   "Auto-closing %s changes with existing patch sets and %s with new patch sets",
                   existingPatchSets, newPatchSets);
               bu.execute();
             } catch (IOException | OrmException | PermissionBackendException e) {
-              logError("Failed to auto-close changes", e);
+              logger.atSevere().withCause(e).log("Failed to auto-close changes");
             }
             return null;
           },
@@ -3001,9 +3137,17 @@
               .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
               .build());
     } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
+      logger.atSevere().withCause(e).log("Can't insert patchset");
     } catch (UpdateException e) {
-      logError("Failed to auto-close changes", e);
+      logger.atSevere().withCause(e).log("Failed to auto-close changes");
+    }
+  }
+
+  private Optional<ChangeNotes> getChangeNotes(Change.Id changeId) throws OrmException {
+    try {
+      return Optional.of(notesFactory.createChecked(db, project.getNameKey(), changeId));
+    } catch (NoSuchChangeException e) {
+      return Optional.empty();
     }
   }
 
@@ -3017,45 +3161,6 @@
     }
   }
 
-  private <T> T executeRequestValidation(Action<T> action)
-      throws IOException, PermissionBackendException, OrmException {
-    try {
-      // The request validation needs to do an account query to lookup accounts by preferred email,
-      // if that index query fails the request validation should be retried.
-      return retryHelper.execute(ActionType.INDEX_QUERY, action, OrmException.class::isInstance);
-    } catch (Exception t) {
-      Throwables.throwIfInstanceOf(t, IOException.class);
-      Throwables.throwIfInstanceOf(t, PermissionBackendException.class);
-      Throwables.throwIfInstanceOf(t, OrmException.class);
-      throw new OrmException(t);
-    }
-  }
-
-  private void updateAccountInfo() {
-    if (setFullNameTo == null) {
-      return;
-    }
-    logDebug("Updating full name of caller");
-    try {
-      Optional<AccountState> accountState =
-          accountsUpdateProvider
-              .get()
-              .update(
-                  "Set Full Name on Receive Commits",
-                  user.getAccountId(),
-                  (a, u) -> {
-                    if (Strings.isNullOrEmpty(a.getAccount().getFullName())) {
-                      u.setFullName(setFullNameTo);
-                    }
-                  });
-      accountState
-          .map(AccountState::getAccount)
-          .ifPresent(a -> user.getAccount().setFullName(a.getFullName()));
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      logWarn("Failed to update full name of caller", e);
-    }
-  }
-
   private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
       throws OrmException {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
@@ -3069,23 +3174,15 @@
     return r;
   }
 
-  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
-    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
-    if (res.isEmpty()) {
-      return Optional.empty();
-    }
-    return Optional.of(res.get(0));
-  }
-
+  // allRefsWatcher hooks into the protocol negotation to get a list of all known refs.
+  // This is used as a cache of ref -> sha1 values, and to build an inverse index
+  // of (change => list of refs) and a (SHA1 => refs).
   private Map<String, Ref> allRefs() {
     return allRefsWatcher.getAllRefs();
   }
 
-  private void reject(@Nullable ReceiveCommand cmd, String why) {
-    if (cmd != null) {
-      cmd.setResult(REJECTED_OTHER_REASON, why);
-      commandProgress.update(1);
-    }
+  private static void reject(ReceiveCommand cmd, String why) {
+    cmd.setResult(REJECTED_OTHER_REASON, why);
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
@@ -3095,46 +3192,4 @@
   private static boolean isConfig(ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(receiveId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(receiveId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(receiveId + msg, arg1, arg2);
-  }
-
-  private void logDebug(
-      String msg, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3);
-  }
-
-  private void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logWarn(msg, null);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index 92723e0..03a1b33 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -20,12 +20,11 @@
   public static final String PUSH_OPTION_SKIP_VALIDATION = "skip-validation";
 
   @VisibleForTesting
-  public static final String ONLY_OWNER_CAN_MODIFY_WIP =
-      "only change owner can modify Work-in-Progress";
+  public static final String ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP =
+      "only change owner or project owner can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
-      "Please read the documentation and contact an administrator\n"
-          + "if you feel the configuration is incorrect";
+      "Contact an administrator to fix the permissions";
 
   static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
       "same Change-Id in multiple changes.\n"
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 36c5005..3e7942f 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -309,7 +309,6 @@
     approvalCopier.copyInReviewDb(
         ctx.getDb(),
         ctx.getNotes(),
-        ctx.getUser(),
         newPatchSet,
         ctx.getRevWalk(),
         ctx.getRepoView().getConfig(),
@@ -408,7 +407,6 @@
           approvalsUtil.byPatchSetUser(
               ctx.getDb(),
               ctx.getNotes(),
-              ctx.getUser(),
               priorPatchSetId,
               ctx.getAccountId(),
               ctx.getRevWalk(),
@@ -541,10 +539,7 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     List<LabelType> labels =
-        projectCache
-            .checkedGet(ctx.getProject())
-            .getLabelTypes(notes, ctx.getUser())
-            .getLabelTypes();
+        projectCache.checkedGet(ctx.getProject()).getLabelTypes(notes).getLabelTypes();
     Map<String, Short> allApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
     for (LabelType lt : labels) {
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 5462631..e9fe562 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountConfig;
 import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.inject.Inject;
@@ -38,21 +39,30 @@
 public class AccountValidator {
 
   private final Provider<IdentifiedUser> self;
+  private final AllUsersName allUsersName;
   private final OutgoingEmailValidator emailValidator;
 
   @Inject
-  public AccountValidator(Provider<IdentifiedUser> self, OutgoingEmailValidator emailValidator) {
+  public AccountValidator(
+      Provider<IdentifiedUser> self,
+      AllUsersName allUsersName,
+      OutgoingEmailValidator emailValidator) {
     this.self = self;
+    this.allUsersName = allUsersName;
     this.emailValidator = emailValidator;
   }
 
   public List<String> validate(
-      Account.Id accountId, Repository repo, RevWalk rw, @Nullable ObjectId oldId, ObjectId newId)
+      Account.Id accountId,
+      Repository allUsersRepo,
+      RevWalk rw,
+      @Nullable ObjectId oldId,
+      ObjectId newId)
       throws IOException {
     Optional<Account> oldAccount = Optional.empty();
     if (oldId != null && !ObjectId.zeroId().equals(oldId)) {
       try {
-        oldAccount = loadAccount(accountId, repo, rw, oldId, null);
+        oldAccount = loadAccount(accountId, allUsersRepo, rw, oldId, null);
       } catch (ConfigInvalidException e) {
         // ignore, maybe the new commit is repairing it now
       }
@@ -61,7 +71,7 @@
     List<String> messages = new ArrayList<>();
     Optional<Account> newAccount;
     try {
-      newAccount = loadAccount(accountId, repo, rw, newId, messages);
+      newAccount = loadAccount(accountId, allUsersRepo, rw, newId, messages);
     } catch (ConfigInvalidException e) {
       return ImmutableList.of(
           String.format(
@@ -94,14 +104,14 @@
 
   private Optional<Account> loadAccount(
       Account.Id accountId,
-      Repository repo,
+      Repository allUsersRepo,
       RevWalk rw,
       ObjectId commit,
       @Nullable List<String> messages)
       throws IOException, ConfigInvalidException {
     rw.reset();
-    AccountConfig accountConfig = new AccountConfig(accountId, repo);
-    accountConfig.load(rw, commit);
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo);
+    accountConfig.load(allUsersName, rw, commit);
     if (messages != null) {
       messages.addAll(
           accountConfig
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 932d1f8..7930fe8 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -35,9 +35,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Branch.NameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -46,11 +44,9 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
@@ -79,6 +75,9 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 
+/**
+ * Represents a list of CommitValidationListeners to run for a push to one branch of one project.
+ */
 public class CommitValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -124,15 +123,15 @@
     }
 
     public CommitValidators forReceiveCommits(
-        PermissionBackend.ForRef perm,
+        PermissionBackend.ForProject forProject,
         Branch.NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
-        Repository repo,
+        NoteMap rejectCommits,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -158,13 +157,14 @@
     }
 
     public CommitValidators forGerritCommits(
-        ForRef perm,
+        PermissionBackend.ForProject forProject,
         NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -188,7 +188,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        Project.NameKey project, PermissionBackend.ForRef perm, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, Branch.NameKey branch, IdentifiedUser user)
         throws IOException {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
@@ -203,10 +203,11 @@
       //    discuss what to do about it.
       //  - Plugin validators may do things like require certain commit message
       //    formats, so we play it safe and exclude them.
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
-              new ProjectStateValidationListener(projectCache.checkedGet(project)),
+              new ProjectStateValidationListener(projectCache.checkedGet(branch.getParentKey())),
               new AuthorUploaderValidator(user, perm, canonicalWebUrl),
               new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
     }
@@ -237,26 +238,17 @@
 
   public static class ChangeIdValidator implements CommitValidationListener {
     private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
-    private static final String MISSING_CHANGE_ID_MSG =
-        "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer";
+    private static final String MISSING_CHANGE_ID_MSG = "missing Change-Id in message footer";
     private static final String MISSING_SUBJECT_MSG =
-        "[%s] missing subject; "
-            + FooterConstants.CHANGE_ID.getName()
-            + " must be in commit message footer";
+        "missing subject; Change-Id must be in message footer";
     private static final String MULTIPLE_CHANGE_ID_MSG =
-        "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer";
+        "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
-        "[%s] invalid "
-            + FooterConstants.CHANGE_ID.getName()
-            + " line format in commit message footer";
+        "invalid Change-Id line format in message footer";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
-        "[%s] "
-            + FooterConstants.CHANGE_ID.getName()
-            + " in commit message footer does not match"
-            + FooterConstants.CHANGE_ID.getName()
-            + " of target change";
+        "Change-Id in message footer does not match Change-Id of target change";
 
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
@@ -291,35 +283,29 @@
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new ArrayList<>();
       List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
-      String sha1 = commit.abbreviate(RevId.ABBREV_LEN).name();
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
         if (shortMsg.startsWith(CHANGE_ID_PREFIX)
             && CHANGE_ID.matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()).matches()) {
-          String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
-          throw new CommitValidationException(errMsg);
+          throw new CommitValidationException(MISSING_SUBJECT_MSG);
         }
         if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) {
-          String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
-          throw new CommitValidationException(errMsg, messages);
+          messages.add(getMissingChangeIdErrorMsg(MISSING_CHANGE_ID_MSG, commit));
+          throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
         }
       } else if (idList.size() > 1) {
-        String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1);
-        throw new CommitValidationException(errMsg, messages);
+        throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
         String v = idList.get(idList.size() - 1).trim();
         // Reject Change-Ids with wrong format and invalid placeholder ID from
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
-          String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
-          throw new CommitValidationException(errMsg, messages);
+          messages.add(getMissingChangeIdErrorMsg(INVALID_CHANGE_ID_MSG, receiveEvent.commit));
+          throw new CommitValidationException(INVALID_CHANGE_ID_MSG, messages);
         }
         if (change != null && !v.equals(change.getKey().get())) {
-          String errMsg = String.format(CHANGE_ID_MISMATCH_MSG, sha1);
-          throw new CommitValidationException(errMsg);
+          throw new CommitValidationException(CHANGE_ID_MISMATCH_MSG);
         }
       }
 
@@ -333,29 +319,28 @@
 
     private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg, RevCommit c) {
       StringBuilder sb = new StringBuilder();
-      sb.append("ERROR: ").append(errMsg);
+      sb.append("ERROR: ").append(errMsg).append("\n");
 
+      boolean hinted = false;
       if (c.getFullMessage().contains(CHANGE_ID_PREFIX)) {
         String lastLine = Iterables.getLast(Splitter.on('\n').split(c.getFullMessage()), "");
         if (!lastLine.contains(CHANGE_ID_PREFIX)) {
-          sb.append('\n');
-          sb.append('\n');
-          sb.append("Hint: A potential ");
-          sb.append(FooterConstants.CHANGE_ID.getName());
-          sb.append("Change-Id was found, but it was not in the ");
-          sb.append("footer (last paragraph) of the commit message.");
+          hinted = true;
+          sb.append("\n")
+              .append("Hint: run\n")
+              .append("  git commit --amend\n")
+              .append("and move 'Change-Id: Ixxx..' to the bottom on a separate line\n");
         }
       }
-      sb.append('\n');
-      sb.append('\n');
-      sb.append("Hint: To automatically insert ");
-      sb.append(FooterConstants.CHANGE_ID.getName());
-      sb.append(", install the hook:\n");
-      sb.append(getCommitMessageHookInstallationHint());
-      sb.append('\n');
-      sb.append("And then amend the commit:\n");
-      sb.append("  git commit --amend\n");
 
+      // Print only one hint to avoid overwhelming the user.
+      if (!hinted) {
+        sb.append("\nHint: to automatically insert a Change-Id, install the hook:\n")
+            .append(getCommitMessageHookInstallationHint())
+            .append("\n")
+            .append("and then amend the commit:\n")
+            .append("  git commit --amend\n");
+      }
       return new CommitValidationMessage(sb.toString(), false);
     }
 
@@ -368,10 +353,9 @@
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
       if (hostKeys.isEmpty()) {
-        String p = "${gitdir}/hooks/commit-msg";
         return String.format(
-            "  gitdir=$(git rev-parse --git-dir); curl -o %s %stools/hooks/commit-msg ; chmod +x %s",
-            p, canonicalWebUrl, p);
+            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
+            canonicalWebUrl);
       }
 
       // SSH keys exist, so the hook can be installed with scp.
@@ -549,7 +533,7 @@
           perm.check(RefPermission.FORGE_COMMITTER);
         } catch (AuthException denied) {
           throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in commit message footer");
+              "not Signed-off-by author/committer/uploader in message footer");
         } catch (PermissionBackendException e) {
           logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
           throw new CommitValidationException("internal auth error");
@@ -584,8 +568,7 @@
         return Collections.emptyList();
       } catch (AuthException e) {
         throw new CommitValidationException(
-            "invalid author",
-            invalidEmail(receiveEvent.commit, "author", author, user, canonicalWebUrl));
+            "invalid author", invalidEmail("author", author, user, canonicalWebUrl));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -618,8 +601,7 @@
         return Collections.emptyList();
       } catch (AuthException e) {
         throw new CommitValidationException(
-            "invalid committer",
-            invalidEmail(receiveEvent.commit, "committer", committer, user, canonicalWebUrl));
+            "invalid committer", invalidEmail("committer", committer, user, canonicalWebUrl));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -839,42 +821,30 @@
   }
 
   private static CommitValidationMessage invalidEmail(
-      RevCommit c,
-      String type,
-      PersonIdent who,
-      IdentifiedUser currentUser,
-      String canonicalWebUrl) {
+      String type, PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
     StringBuilder sb = new StringBuilder();
-    sb.append("\n");
-    sb.append("ERROR:  In commit ").append(c.name()).append("\n");
-    sb.append("ERROR:  ")
-        .append(type)
-        .append(" email address ")
+
+    sb.append("email address ")
         .append(who.getEmailAddress())
-        .append("\n");
-    sb.append("ERROR:  does not match your user account and you have no 'forge ")
+        .append(" is not registered in your account, and you lack 'forge ")
         .append(type)
         .append("' permission.\n");
-    sb.append("ERROR:\n");
+
     if (currentUser.getEmailAddresses().isEmpty()) {
-      sb.append("ERROR:  You have not registered any email addresses.\n");
+      sb.append("You have not registered any email addresses.\n");
     } else {
-      sb.append("ERROR:  The following addresses are currently registered:\n");
+      sb.append("The following addresses are currently registered:\n");
       for (String address : currentUser.getEmailAddresses()) {
-        sb.append("ERROR:    ").append(address).append("\n");
+        sb.append("   ").append(address).append("\n");
       }
     }
-    sb.append("ERROR:\n");
+
     if (canonicalWebUrl != null) {
-      sb.append("ERROR:  To register an email address, please visit:\n");
-      sb.append("ERROR:  ")
-          .append(canonicalWebUrl)
-          .append("#")
-          .append(PageLinks.SETTINGS_CONTACT)
-          .append("\n");
+      sb.append("To register an email address, visit:\n");
+      sb.append(canonicalWebUrl).append("#").append(PageLinks.SETTINGS_CONTACT).append("\n");
     }
     sb.append("\n");
-    return new CommitValidationMessage(sb.toString(), false);
+    return new CommitValidationMessage(sb.toString(), true);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 94d9996..c4df4dd 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -32,12 +32,14 @@
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -48,6 +50,7 @@
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -114,12 +117,17 @@
         "Change contains a project configuration that changes the parent"
             + " project.\n"
             + "The change must be submitted by a Gerrit administrator.";
+    private static final String SET_BY_OWNER =
+        "Change contains a project configuration that changes the parent"
+            + " project.\n"
+            + "The change must be submitted by a Gerrit administrator or the project owner.";
 
     private final AllProjectsName allProjectsName;
     private final AllUsersName allUsersName;
     private final ProjectCache projectCache;
     private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+    private final boolean allowProjectOwnersToChangeParent;
 
     public interface Factory {
       ProjectConfigValidator create();
@@ -131,12 +139,15 @@
         AllUsersName allUsersName,
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
-        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        @GerritServerConfig Config config) {
       this.allProjectsName = allProjectsName;
       this.allUsersName = allUsersName;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.allowProjectOwnersToChangeParent =
+          config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
     }
 
     @Override
@@ -152,7 +163,7 @@
         final Project.NameKey newParent;
         try {
           ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
-          cfg.load(repo, commit);
+          cfg.load(destProject.getNameKey(), repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
           if (oldParent == null) {
@@ -162,13 +173,27 @@
             }
           } else {
             if (!oldParent.equals(newParent)) {
-              try {
-                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-              } catch (AuthException e) {
-                throw new MergeValidationException(SET_BY_ADMIN);
-              } catch (PermissionBackendException e) {
-                logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
-                throw new MergeValidationException("validation unavailable");
+              if (!allowProjectOwnersToChangeParent) {
+                try {
+                  permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+                } catch (AuthException e) {
+                  throw new MergeValidationException(SET_BY_ADMIN);
+                } catch (PermissionBackendException e) {
+                  logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
+                  throw new MergeValidationException("validation unavailable");
+                }
+              } else {
+                try {
+                  permissionBackend
+                      .user(caller)
+                      .project(destProject.getNameKey())
+                      .check(ProjectPermission.WRITE_CONFIG);
+                } catch (AuthException e) {
+                  throw new MergeValidationException(SET_BY_OWNER);
+                } catch (PermissionBackendException e) {
+                  logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
+                  throw new MergeValidationException("validation unavailable");
+                }
               }
               if (allUsersName.equals(destProject.getNameKey())
                   && !allProjectsName.equals(newParent)) {
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
new file mode 100644
index 0000000..c543a6e
--- /dev/null
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 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.group;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+
+public interface GroupAuditService {
+
+  void dispatchAddMembers(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<Id> addedMembers,
+      Timestamp addedOn);
+
+  void dispatchDeleteMembers(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<Account.Id> deletedMembers,
+      Timestamp deletedOn);
+
+  void dispatchAddSubgroups(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<AccountGroup.UUID> addedSubgroups,
+      Timestamp addedOn);
+
+  void dispatchDeleteSubgroups(
+      Account.Id actor,
+      AccountGroup.UUID updatedGroup,
+      ImmutableSet<AccountGroup.UUID> deletedSubgroups,
+      Timestamp deletedOn);
+}
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index c6d1a6f..61fdb60 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.NoteDbUtil;
 import com.google.inject.Inject;
@@ -49,10 +50,12 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String serverId;
+  private final AllUsersName allUsersName;
 
   @Inject
-  public AuditLogReader(@GerritServerId String serverId) {
+  public AuditLogReader(@GerritServerId String serverId, AllUsersName allUsersName) {
     this.serverId = serverId;
+    this.allUsersName = allUsersName;
   }
 
   // Having separate methods for reading the two types of audit records mirrors the split in
@@ -60,8 +63,8 @@
   // revisit this, e.g. to do only a single walk, or even change the record types.
 
   public ImmutableList<AccountGroupMemberAudit> getMembersAudit(
-      Repository repo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
-    return getMembersAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+      Repository allUsersRepo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
+    return getMembersAudit(getGroupId(allUsersRepo, uuid), parseCommits(allUsersRepo, uuid));
   }
 
   private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
@@ -211,10 +214,13 @@
     }
   }
 
-  private AccountGroup.Id getGroupId(Repository repo, AccountGroup.UUID uuid)
+  private AccountGroup.Id getGroupId(Repository allUsersRepo, AccountGroup.UUID uuid)
       throws ConfigInvalidException, IOException {
     // TODO(dborowitz): This re-walks all commits just to find createdOn, which we don't need.
-    return GroupConfig.loadForGroup(repo, uuid).getLoadedGroup().get().getId();
+    return GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid)
+        .getLoadedGroup()
+        .get()
+        .getId();
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 62f87c6..2fae4cc 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -51,9 +52,10 @@
  * A representation of a group in NoteDb.
  *
  * <p>Groups in NoteDb can be created by following the descriptions of {@link
- * #createForNewGroup(Repository, InternalGroupCreation)}. For reading groups from NoteDb or
- * updating them, refer to {@link #loadForGroup(Repository, AccountGroup.UUID)} or {@link
- * #loadForGroupSnapshot(Repository, AccountGroup.UUID, ObjectId)}.
+ * #createForNewGroup(Project.NameKey, Repository, InternalGroupCreation)}. For reading groups from
+ * NoteDb or updating them, refer to {@link #loadForGroup(Project.NameKey, Repository,
+ * AccountGroup.UUID)} or {@link #loadForGroupSnapshot(Project.NameKey, Repository,
+ * AccountGroup.UUID, ObjectId)}.
  *
  * <p><strong>Note: </strong>Any modification (group creation or update) only becomes permanent (and
  * hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
@@ -100,6 +102,7 @@
    * <p><strong>Note: </strong>The returned {@code GroupConfig} has to be committed via {@link
    * #commit(MetaDataUpdate)} in order to create the group for real.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupCreation an {@code InternalGroupCreation} specifying all properties which are
    *     required for a new group
@@ -110,10 +113,10 @@
    * @throws OrmDuplicateKeyException if a group with the same UUID already exists
    */
   public static GroupConfig createForNewGroup(
-      Repository repository, InternalGroupCreation groupCreation)
+      Project.NameKey projectName, Repository repository, InternalGroupCreation groupCreation)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
     GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID());
-    groupConfig.load(repository);
+    groupConfig.load(projectName, repository);
     groupConfig.setGroupCreation(groupCreation);
     return groupConfig;
   }
@@ -131,27 +134,30 @@
    * {@code InternalGroupUpdate} via {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)}
    * and committing the {@code GroupConfig} via {@link #commit(MetaDataUpdate)}.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @return a {@code GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
-  public static GroupConfig loadForGroup(Repository repository, AccountGroup.UUID groupUuid)
+  public static GroupConfig loadForGroup(
+      Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
-    groupConfig.load(repository);
+    groupConfig.load(projectName, repository);
     return groupConfig;
   }
 
   /**
    * Creates a {@code GroupConfig} for an existing group at a specific revision of the repository.
    *
-   * <p>This method behaves nearly the same as {@link #loadForGroup(Repository, AccountGroup.UUID)}.
-   * The only difference is that {@link #loadForGroup(Repository, AccountGroup.UUID)} loads the
-   * group from the current state of the repository whereas this method loads the group at a
-   * specific (maybe past) revision.
+   * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
+   * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
+   * Repository, AccountGroup.UUID)} loads the group from the current state of the repository
+   * whereas this method loads the group at a specific (maybe past) revision.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @param commitId the revision of the repository at which the group should be loaded
@@ -160,10 +166,13 @@
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
   public static GroupConfig loadForGroupSnapshot(
-      Repository repository, AccountGroup.UUID groupUuid, ObjectId commitId)
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      ObjectId commitId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
-    groupConfig.load(repository, commitId);
+    groupConfig.load(projectName, repository, commitId);
     return groupConfig;
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 1b74241..440bc42 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -25,10 +25,12 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Multiset;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -62,12 +64,12 @@
  * map of name/UUID pairs and manage it with this class.
  *
  * <p>To claim the name for a new group, create an instance of {@code GroupNameNotes} via {@link
- * #forNewGroup(Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call {@link
- * #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it. For
- * renaming, call {@link #forRename(Repository, AccountGroup.UUID, AccountGroup.NameKey,
- * AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}. Both times, the
- * creation of the {@code GroupNameNotes} will fail if the (new) name is already used. Committing
- * the {@code GroupNameNotes} is necessary to make the adjustments for real.
+ * #forNewGroup(Project.NameKey, Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call
+ * {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it.
+ * For renaming, call {@link #forRename(Project.NameKey, Repository, AccountGroup.UUID,
+ * AccountGroup.NameKey, AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}.
+ * Both times, the creation of the {@code GroupNameNotes} will fail if the (new) name is already
+ * used. Committing the {@code GroupNameNotes} is necessary to make the adjustments for real.
  *
  * <p>The map has an additional benefit: We can quickly iterate over all group name/UUID pairs
  * without having to load all groups completely (which is costly).
@@ -87,6 +89,8 @@
  * </ul>
  */
 public class GroupNameNotes extends VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String SECTION_NAME = "group";
   private static final String UUID_PARAM = "uuid";
   private static final String NAME_PARAM = "name";
@@ -101,6 +105,7 @@
    * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
    * order to claim the new name and free up the old one.
    *
+   * @param projectName the name of the project which holds the commits of the notes
    * @param repository the repository which holds the commits of the notes
    * @param groupUuid the UUID of the group which is renamed
    * @param oldName the current name of the group
@@ -112,6 +117,7 @@
    * @throws OrmDuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forRename(
+      Project.NameKey projectName,
       Repository repository,
       AccountGroup.UUID groupUuid,
       AccountGroup.NameKey oldName,
@@ -121,7 +127,7 @@
     checkNotNull(newName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -133,6 +139,7 @@
    * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
    * order to claim the new name.
    *
+   * @param projectName the name of the project which holds the commits of the notes
    * @param repository the repository which holds the commits of the notes
    * @param groupUuid the UUID of the new group
    * @param groupName the name of the new group
@@ -142,12 +149,15 @@
    * @throws OrmDuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forNewGroup(
-      Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      AccountGroup.NameKey groupName)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
     checkNotNull(groupName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -323,6 +333,8 @@
   protected void onLoad() throws IOException, ConfigInvalidException {
     nameConflicting = false;
 
+    logger.atFine().log("Reading group notes");
+
     if (revision != null) {
       NoteMap noteMap = NoteMap.read(reader, revision);
       if (newGroupName.isPresent()) {
@@ -365,6 +377,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating group notes");
+
     NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
     if (oldGroupName.isPresent()) {
       removeNote(noteMap, oldGroupName.get(), inserter);
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 46fa998..f2289d4 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -70,14 +70,14 @@
   public Optional<InternalGroup> getGroup(AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      return getGroupFromNoteDb(allUsersRepo, groupUuid);
+      return getGroupFromNoteDb(allUsersName, allUsersRepo, groupUuid);
     }
   }
 
   private static Optional<InternalGroup> getGroupFromNoteDb(
-      Repository allUsersRepository, AccountGroup.UUID groupUuid)
+      AllUsersName allUsersName, Repository allUsersRepository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepository, groupUuid);
     Optional<InternalGroup> loadedGroup = groupConfig.getLoadedGroup();
     if (loadedGroup.isPresent()) {
       // Check consistency with group name notes.
@@ -110,16 +110,18 @@
    */
   public Stream<AccountGroup.UUID> getExternalGroups() throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      return getExternalGroupsFromNoteDb(allUsersRepo);
+      return getExternalGroupsFromNoteDb(allUsersName, allUsersRepo);
     }
   }
 
-  private static Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(Repository allUsersRepo)
+  private static Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     ImmutableList<GroupReference> allInternalGroups = GroupNameNotes.loadAllGroups(allUsersRepo);
     ImmutableSet.Builder<AccountGroup.UUID> allSubgroups = ImmutableSet.builder();
     for (GroupReference internalGroup : allInternalGroups) {
-      Optional<InternalGroup> group = getGroupFromNoteDb(allUsersRepo, internalGroup.getUUID());
+      Optional<InternalGroup> group =
+          getGroupFromNoteDb(allUsersName, allUsersRepo, internalGroup.getUUID());
       group.map(InternalGroup::getSubgroups).ifPresent(allSubgroups::addAll);
     }
     return allSubgroups
@@ -131,15 +133,16 @@
   /**
    * Returns the membership audit records for a given group.
    *
-   * @param repo All-Users repository.
+   * @param allUsersRepo All-Users repository.
    * @param groupUuid the UUID of the group
    * @return the audit records, in arbitrary order; empty if the group does not exist
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupMemberAudit> getMembersAudit(Repository repo, AccountGroup.UUID groupUuid)
+  public List<AccountGroupMemberAudit> getMembersAudit(
+      Repository allUsersRepo, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
-    return auditLogReader.getMembersAudit(repo, groupUuid);
+    return auditLogReader.getMembersAudit(allUsersRepo, groupUuid);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index b5324f1..3182028 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -28,7 +28,9 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -53,6 +55,13 @@
 public class GroupsNoteDbConsistencyChecker {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final AllUsersName allUsersName;
+
+  @Inject
+  GroupsNoteDbConsistencyChecker(AllUsersName allUsersName) {
+    this.allUsersName = allUsersName;
+  }
+
   /**
    * The result of a consistency check. The UUID map is only non-null if no problems were detected.
    */
@@ -63,15 +72,15 @@
   }
 
   /** Checks for problems with the given All-Users repo. */
-  public Result check(Repository repo) throws IOException {
-    Result r = doCheck(repo);
+  public Result check(Repository allUsersRepo) throws IOException {
+    Result r = doCheck(allUsersRepo);
     if (!r.problems.isEmpty()) {
       r.uuidToGroupMap = null;
     }
     return r;
   }
 
-  private Result doCheck(Repository repo) throws IOException {
+  private Result doCheck(Repository allUsersRepo) throws IOException {
     Result result = new Result();
     result.problems = new ArrayList<>();
     result.uuidToGroupMap = new HashMap<>();
@@ -79,9 +88,9 @@
     BiMap<AccountGroup.UUID, String> uuidNameBiMap = HashBiMap.create();
 
     // Get all refs in an attempt to avoid seeing half committed group updates.
-    Map<String, Ref> refs = repo.getAllRefs();
-    readGroups(repo, refs, result);
-    readGroupNames(repo, refs, result, uuidNameBiMap);
+    Map<String, Ref> refs = allUsersRepo.getAllRefs();
+    readGroups(allUsersRepo, refs, result);
+    readGroupNames(allUsersRepo, refs, result, uuidNameBiMap);
     // The sequential IDs are not keys in NoteDb, so no need to check them.
 
     if (!result.problems.isEmpty()) {
@@ -94,7 +103,7 @@
     return result;
   }
 
-  private void readGroups(Repository repo, Map<String, Ref> refs, Result result)
+  private void readGroups(Repository allUsersRepo, Map<String, Ref> refs, Result result)
       throws IOException {
     for (Map.Entry<String, Ref> entry : refs.entrySet()) {
       if (!entry.getKey().startsWith(RefNames.REFS_GROUPS)) {
@@ -108,7 +117,8 @@
       }
       try {
         GroupConfig cfg =
-            GroupConfig.loadForGroupSnapshot(repo, uuid, entry.getValue().getObjectId());
+            GroupConfig.loadForGroupSnapshot(
+                allUsersName, allUsersRepo, uuid, entry.getValue().getObjectId());
         result.uuidToGroupMap.put(uuid, cfg.getLoadedGroup().get());
       } catch (ConfigInvalidException e) {
         result.problems.add(error("group %s does not parse: %s", uuid, e.getMessage()));
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 38db2e6..314825b 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -31,13 +31,13 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.update.RefUpdateUtil;
@@ -89,7 +89,7 @@
   private final GroupCache groupCache;
   private final GroupIncludeCache groupIncludeCache;
   private final Provider<GroupIndexer> indexer;
-  private final AuditService auditService;
+  private final GroupAuditService groupAuditService;
   private final RenameGroupOp.Factory renameGroupOpFactory;
   @Nullable private final IdentifiedUser currentUser;
   private final AuditLogFormatter auditLogFormatter;
@@ -106,7 +106,7 @@
       GroupCache groupCache,
       GroupIncludeCache groupIncludeCache,
       Provider<GroupIndexer> indexer,
-      AuditService auditService,
+      GroupAuditService auditService,
       AccountCache accountCache,
       RenameGroupOp.Factory renameGroupOpFactory,
       @GerritServerId String serverId,
@@ -120,7 +120,7 @@
     this.groupCache = groupCache;
     this.groupIncludeCache = groupIncludeCache;
     this.indexer = indexer;
-    this.auditService = auditService;
+    this.groupAuditService = auditService;
     this.renameGroupOpFactory = renameGroupOpFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.retryHelper = retryHelper;
@@ -232,9 +232,11 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
       GroupNameNotes groupNameNotes =
-          GroupNameNotes.forNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+          GroupNameNotes.forNewGroup(
+              allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
-      GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+      GroupConfig groupConfig =
+          GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
       groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
@@ -269,7 +271,7 @@
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
       groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
       if (!groupConfig.getLoadedGroup().isPresent()) {
         throw new NoSuchGroupException(groupUuid);
@@ -280,7 +282,8 @@
       if (groupUpdate.getName().isPresent()) {
         AccountGroup.NameKey oldName = originalGroup.getNameKey();
         AccountGroup.NameKey newName = groupUpdate.getName().get();
-        groupNameNotes = GroupNameNotes.forRename(allUsersRepo, groupUuid, oldName, newName);
+        groupNameNotes =
+            GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
       }
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
@@ -384,14 +387,14 @@
     }
 
     if (!createdGroup.getMembers().isEmpty()) {
-      auditService.dispatchAddMembers(
+      groupAuditService.dispatchAddMembers(
           currentUser.getAccountId(),
           createdGroup.getGroupUUID(),
           createdGroup.getMembers(),
           createdGroup.getCreatedOn());
     }
     if (!createdGroup.getSubgroups().isEmpty()) {
-      auditService.dispatchAddSubgroups(
+      groupAuditService.dispatchAddSubgroups(
           currentUser.getAccountId(),
           createdGroup.getGroupUUID(),
           createdGroup.getSubgroups(),
@@ -405,19 +408,19 @@
     }
 
     if (!result.getAddedMembers().isEmpty()) {
-      auditService.dispatchAddMembers(
+      groupAuditService.dispatchAddMembers(
           currentUser.getAccountId(), result.getGroupUuid(), result.getAddedMembers(), updatedOn);
     }
     if (!result.getDeletedMembers().isEmpty()) {
-      auditService.dispatchDeleteMembers(
+      groupAuditService.dispatchDeleteMembers(
           currentUser.getAccountId(), result.getGroupUuid(), result.getDeletedMembers(), updatedOn);
     }
     if (!result.getAddedSubgroups().isEmpty()) {
-      auditService.dispatchAddSubgroups(
+      groupAuditService.dispatchAddSubgroups(
           currentUser.getAccountId(), result.getGroupUuid(), result.getAddedSubgroups(), updatedOn);
     }
     if (!result.getDeletedSubgroups().isEmpty()) {
-      auditService.dispatchDeleteSubgroups(
+      groupAuditService.dispatchDeleteSubgroups(
           currentUser.getAccountId(),
           result.getGroupUuid(),
           result.getDeletedSubgroups(),
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index d957558..3c9538c 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -104,7 +104,7 @@
 
   public IndexModule(
       ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
-    this.threads = -1;
+    this.threads = 0;
     this.interactiveExecutor = interactiveExecutor;
     this.batchExecutor = batchExecutor;
     this.closeExecutorsOnShutdown = false;
@@ -211,11 +211,12 @@
       return interactiveExecutor;
     }
     int threads = this.threads;
-    if (threads <= 0) {
-      threads = config.getInt("index", null, "threads", 0);
-    }
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
+      threads =
+          config.getInt(
+              "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive", true));
@@ -230,7 +231,9 @@
       return batchExecutor;
     }
     int threads = config.getInt("index", null, "batchThreads", 0);
-    if (threads <= 0) {
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
       threads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 31e2ada..111991c 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -27,11 +27,11 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.index.RefState;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Collections;
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index b7bb0dd..f8c17d1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -22,6 +23,8 @@
 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.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +33,8 @@
 import java.util.Optional;
 
 public class AccountIndexerImpl implements AccountIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     AccountIndexerImpl create(AccountIndexCollection indexes);
 
@@ -70,14 +75,29 @@
 
   @Override
   public void index(Account.Id id) throws IOException {
+    byIdCache.evict(id);
+    Optional<AccountState> accountState = byIdCache.get(id);
+
+    if (accountState.isPresent()) {
+      logger.atFine().log("Replace account %d in index", id.get());
+    } else {
+      logger.atFine().log("Delete account %d from index", id.get());
+    }
+
     for (Index<Account.Id, AccountState> i : getWriteIndexes()) {
       // Evict the cache to get an up-to-date value for sure.
-      byIdCache.evict(id);
-      Optional<AccountState> accountState = byIdCache.get(id);
       if (accountState.isPresent()) {
-        i.replace(accountState.get());
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.replace(accountState.get());
+        }
       } else {
-        i.delete(id);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleteing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.delete(id);
+        }
       }
     }
     fireAccountIndexedEvent(id.get());
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 0015268..acb7236 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -60,7 +59,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(AccountIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<Account.Id> ids;
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 6403d3d..0c3e329 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
@@ -33,7 +34,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.RefState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index babcba1..37e288c 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -136,8 +136,7 @@
     // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
     // the estimate is just used as a heuristic for sorting projects.
     return repo.getRefDatabase()
-        .getRefs(RefNames.REFS_CHANGES)
-        .values()
+        .getRefsByPrefix(RefNames.REFS_CHANGES)
         .stream()
         .map(r -> Change.Id.fromRef(r.getName()))
         .filter(Objects::nonNull)
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 405e6fc..5d12e79 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -43,7 +43,9 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -56,9 +58,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e947e60..d0a9749 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -33,6 +33,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -212,8 +214,14 @@
   }
 
   private void indexImpl(ChangeData cd) throws IOException {
+    logger.atFine().log("Replace change %d in index.", cd.getId().get());
     for (Index<?, ChangeData> i : getWriteIndexes()) {
-      i.replace(cd);
+      try (TraceTimer traceTimer =
+          TraceContext.newTimer(
+              "Replacing change %d in index version %d",
+              cd.getId().get(), i.getSchema().getVersion())) {
+        i.replace(cd);
+      }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
@@ -411,13 +419,17 @@
 
     @Override
     public Void call() throws IOException {
+      logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
       // Implementations should not need to access the DB in order to delete a
       // change ID.
       for (ChangeIndex i : getWriteIndexes()) {
-        i.delete(id);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleteing change %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.delete(id);
+        }
       }
-      logger.atInfo().log("Deleted change %s from index.", id.get());
       fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 208e949..b32ca8c 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -30,12 +30,14 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.RefState;
 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.UsedAt;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -103,6 +105,7 @@
         parsePatterns(cd));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean isStale(
       GitRepositoryManager repoManager,
       Change.Id id,
@@ -174,7 +177,8 @@
       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)));
+      result.put(
+          new Project.NameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
     }
     return result;
   }
@@ -247,7 +251,7 @@
     }
 
     private boolean match(Repository repo, Set<RefState> expected) throws IOException {
-      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
+      for (Ref r : repo.getRefDatabase().getRefsByPrefix(prefix())) {
         if (!match(r.getName())) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 2823c2e..3474934 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -33,7 +33,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -64,7 +63,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(GroupIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<AccountGroup.UUID> uuids;
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index fcbdc57..d6ba253 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -22,6 +23,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +33,8 @@
 import java.util.Optional;
 
 public class GroupIndexerImpl implements GroupIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     GroupIndexerImpl create(GroupIndexCollection indexes);
 
@@ -70,14 +75,29 @@
 
   @Override
   public void index(AccountGroup.UUID uuid) throws IOException {
+    // Evict the cache to get an up-to-date value for sure.
+    groupCache.evict(uuid);
+    Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+
+    if (internalGroup.isPresent()) {
+      logger.atFine().log("Replace group %s in index", uuid.get());
+    } else {
+      logger.atFine().log("Delete group %s from index", uuid.get());
+    }
+
     for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
-      // Evict the cache to get an up-to-date value for sure.
-      groupCache.evict(uuid);
-      Optional<InternalGroup> internalGroup = groupCache.get(uuid);
       if (internalGroup.isPresent()) {
-        i.replace(internalGroup.get());
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+          i.replace(internalGroup.get());
+        }
       } else {
-        i.delete(uuid);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+          i.delete(uuid);
+        }
       }
     }
     fireGroupIndexedEvent(uuid.get());
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
index a79bb7a..c2a28af 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.project;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -23,6 +24,8 @@
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +35,8 @@
 import java.util.Collections;
 
 public class ProjectIndexerImpl implements ProjectIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     ProjectIndexerImpl create(ProjectIndexCollection indexes);
 
@@ -69,14 +74,26 @@
   public void index(Project.NameKey nameKey) throws IOException {
     ProjectState projectState = projectCache.get(nameKey);
     if (projectState != null) {
+      logger.atFine().log("Replace project %s in index", nameKey.get());
       ProjectData projectData = projectState.toProjectData();
       for (ProjectIndex i : getWriteIndexes()) {
-        i.replace(projectData);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing project %s in index version %d",
+                nameKey.get(), i.getSchema().getVersion())) {
+          i.replace(projectData);
+        }
       }
       fireProjectIndexedEvent(nameKey.get());
     } else {
+      logger.atFine().log("Delete project %s from index", nameKey.get());
       for (ProjectIndex i : getWriteIndexes()) {
-        i.delete(nameKey);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting project %s in index version %d",
+                nameKey.get(), i.getSchema().getVersion())) {
+          i.delete(nameKey);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
new file mode 100644
index 0000000..5603f08
--- /dev/null
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2018 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.project;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.project.ProjectCache;
+import java.io.IOException;
+import java.util.Optional;
+import javax.inject.Inject;
+
+public class StalenessChecker {
+  private static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+
+  private final ProjectCache projectCache;
+  private final ProjectIndexCollection indexes;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  StalenessChecker(
+      ProjectCache projectCache, ProjectIndexCollection indexes, IndexConfig indexConfig) {
+    this.projectCache = projectCache;
+    this.indexes = indexes;
+    this.indexConfig = indexConfig;
+  }
+
+  public boolean isStale(Project.NameKey project) throws IOException {
+    ProjectData projectData = projectCache.get(project).toProjectData();
+    ProjectIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      return true;
+    }
+
+    SetMultimap<Project.NameKey, RefState> indexedRefStates =
+        RefState.parseStates(result.get().getValue(ProjectField.REF_STATE));
+
+    SetMultimap<Project.NameKey, RefState> currentRefStates =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    projectData
+        .tree()
+        .stream()
+        .filter(p -> p.getProject().getConfigRefState() != null)
+        .forEach(
+            p ->
+                currentRefStates.put(
+                    p.getProject().getNameKey(),
+                    RefState.create(RefNames.REFS_CONFIG, p.getProject().getConfigRefState())));
+
+    return !currentRefStates.equals(indexedRefStates);
+  }
+}
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index 06843c5..15e67af 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -4,6 +4,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/reviewdb:client",
+        "//lib:automaton",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/server/ioutil/HexFormat.java b/java/com/google/gerrit/server/ioutil/HexFormat.java
new file mode 100644
index 0000000..fd9c17a
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/HexFormat.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 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.ioutil;
+
+public class HexFormat {
+  public static String fromInt(int id) {
+    final char[] r = new char[8];
+    for (int p = 7; 0 <= p; p--) {
+      final int h = id & 0xf;
+      r[p] = h < 10 ? (char) ('0' + h) : (char) ('a' + (h - 10));
+      id >>= 4;
+    }
+    return new String(r);
+  }
+}
diff --git a/java/com/google/gerrit/server/util/HostPlatform.java b/java/com/google/gerrit/server/ioutil/HostPlatform.java
similarity index 96%
rename from java/com/google/gerrit/server/util/HostPlatform.java
rename to java/com/google/gerrit/server/ioutil/HostPlatform.java
index 066bd4b..5afda3c 100644
--- a/java/com/google/gerrit/server/util/HostPlatform.java
+++ b/java/com/google/gerrit/server/ioutil/HostPlatform.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.ioutil;
 
 import java.security.AccessController;
 import java.security.PrivilegedAction;
diff --git a/java/com/google/gerrit/server/util/RegexListSearcher.java b/java/com/google/gerrit/server/ioutil/RegexListSearcher.java
similarity index 96%
rename from java/com/google/gerrit/server/util/RegexListSearcher.java
rename to java/com/google/gerrit/server/ioutil/RegexListSearcher.java
index aea1d5c..937a195 100644
--- a/java/com/google/gerrit/server/util/RegexListSearcher.java
+++ b/java/com/google/gerrit/server/ioutil/RegexListSearcher.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.ioutil;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
new file mode 100644
index 0000000..d3211f0
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "logging",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//lib:guava",
+        "//lib/flogger:api",
+    ],
+)
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
new file mode 100644
index 0000000..1e81c29
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2018 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.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.backend.Tags;
+import java.util.concurrent.Callable;
+import java.util.logging.Level;
+
+/**
+ * Logging context for Flogger.
+ *
+ * <p>To configure this logging context for Flogger set the following system property (also see
+ * {@link com.google.common.flogger.backend.system.DefaultPlatform}):
+ *
+ * <ul>
+ *   <li>{@code
+ *       flogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance}.
+ * </ul>
+ */
+public class LoggingContext extends com.google.common.flogger.backend.system.LoggingContext {
+  private static final LoggingContext INSTANCE = new LoggingContext();
+
+  private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
+
+  private LoggingContext() {}
+
+  /** This method is expected to be called via reflection (and might otherwise be unused). */
+  public static LoggingContext getInstance() {
+    return INSTANCE;
+  }
+
+  public static Runnable copy(Runnable runnable) {
+    if (runnable instanceof LoggingContextAwareRunnable) {
+      return runnable;
+    }
+    return new LoggingContextAwareRunnable(runnable);
+  }
+
+  public static <T> Callable<T> copy(Callable<T> callable) {
+    if (callable instanceof LoggingContextAwareCallable) {
+      return callable;
+    }
+    return new LoggingContextAwareCallable<>(callable);
+  }
+
+  @Override
+  public boolean shouldForceLogging(String loggerName, Level level, boolean isEnabled) {
+    return isLoggingForced();
+  }
+
+  @Override
+  public Tags getTags() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.getTags() : Tags.empty();
+  }
+
+  public ImmutableSetMultimap<String, String> getTagsAsMap() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.asMap() : ImmutableSetMultimap.of();
+  }
+
+  boolean addTag(String name, String value) {
+    return getMutableTags().add(name, value);
+  }
+
+  void removeTag(String name, String value) {
+    MutableTags mutableTags = getMutableTags();
+    mutableTags.remove(name, value);
+    if (mutableTags.isEmpty()) {
+      tags.remove();
+    }
+  }
+
+  void setTags(ImmutableSetMultimap<String, String> newTags) {
+    if (newTags.isEmpty()) {
+      tags.remove();
+      return;
+    }
+    getMutableTags().set(newTags);
+  }
+
+  void clearTags() {
+    tags.remove();
+  }
+
+  private MutableTags getMutableTags() {
+    MutableTags mutableTags = tags.get();
+    if (mutableTags == null) {
+      mutableTags = new MutableTags();
+      tags.set(mutableTags);
+    }
+    return mutableTags;
+  }
+
+  boolean isLoggingForced() {
+    Boolean force = forceLogging.get();
+    return force != null ? force : false;
+  }
+
+  boolean forceLogging(boolean force) {
+    Boolean oldValue = forceLogging.get();
+    if (force) {
+      forceLogging.set(true);
+    } else {
+      forceLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
new file mode 100644
index 0000000..6aff5c4
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2018 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.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.concurrent.Callable;
+
+/**
+ * Wrapper for a {@link Callable} that copies the {@link LoggingContext} from the current thread to
+ * the thread that executes the callable.
+ *
+ * <p>The state of the logging context that is copied to the thread that executes the callable is
+ * fixed at the creation time of this wrapper. If the callable is submitted to an executor and is
+ * executed later this means that changes that are done to the logging context in between creating
+ * and executing the callable do not apply.
+ *
+ * <p>See {@link LoggingContextAwareRunnable} for an example.
+ *
+ * @see LoggingContextAwareRunnable
+ */
+class LoggingContextAwareCallable<T> implements Callable<T> {
+  private final Callable<T> callable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+
+  LoggingContextAwareCallable(Callable<T> callable) {
+    this.callable = callable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+  }
+
+  @Override
+  public T call() throws Exception {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      return callable.call();
+    }
+
+    // propagate logging context
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
+    boolean oldForceLogging = loggingCtx.isLoggingForced();
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    try {
+      return callable.call();
+    } finally {
+      loggingCtx.setTags(oldTags);
+      loggingCtx.forceLogging(oldForceLogging);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java b/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java
new file mode 100644
index 0000000..17e152e
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2018 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.logging;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * An {@link ExecutorService} that copies the {@link LoggingContext} on executing a {@link Runnable}
+ * to the executing thread.
+ */
+public class LoggingContextAwareExecutorService implements ExecutorService {
+  private final ExecutorService executorService;
+
+  public LoggingContextAwareExecutorService(ExecutorService executorService) {
+    this.executorService = executorService;
+  }
+
+  @Override
+  public void execute(Runnable command) {
+    executorService.execute(LoggingContext.copy(command));
+  }
+
+  @Override
+  public void shutdown() {
+    executorService.shutdown();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    return executorService.shutdownNow();
+  }
+
+  @Override
+  public boolean isShutdown() {
+    return executorService.isShutdown();
+  }
+
+  @Override
+  public boolean isTerminated() {
+    return executorService.isTerminated();
+  }
+
+  @Override
+  public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+    return executorService.awaitTermination(timeout, unit);
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    return executorService.submit(LoggingContext.copy(task));
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    return executorService.submit(LoggingContext.copy(task), result);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    return executorService.submit(LoggingContext.copy(task));
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException {
+    return executorService.invokeAll(tasks.stream().map(LoggingContext::copy).collect(toList()));
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(
+      Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException {
+    return executorService.invokeAll(
+        tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException, ExecutionException {
+    return executorService.invokeAny(tasks.stream().map(LoggingContext::copy).collect(toList()));
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    return executorService.invokeAny(
+        tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
new file mode 100644
index 0000000..0bd7d00
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2018 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.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+
+/**
+ * Wrapper for a {@link Runnable} that copies the {@link LoggingContext} from the current thread to
+ * the thread that executes the runnable.
+ *
+ * <p>The state of the logging context that is copied to the thread that executes the runnable is
+ * fixed at the creation time of this wrapper. If the runnable is submitted to an executor and is
+ * executed later this means that changes that are done to the logging context in between creating
+ * and executing the runnable do not apply.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ *   try (TraceContext traceContext = TraceContext.newTrace(true, ...)) {
+ *     executor
+ *         .submit(new LoggingContextAwareRunnable(
+ *             () -> {
+ *               // Tracing is enabled since the runnable is created within the TraceContext.
+ *               // Tracing is even enabled if the executor runs the runnable only after the
+ *               // TraceContext was closed.
+ *
+ *               // The tag "foo=bar" is not set, since it was added to the logging context only
+ *               // after this runnable was created.
+ *
+ *               // do stuff
+ *             }))
+ *         .get();
+ *     traceContext.addTag("foo", "bar");
+ *   }
+ * </pre>
+ *
+ * @see LoggingContextAwareCallable
+ */
+public class LoggingContextAwareRunnable implements Runnable {
+  private final Runnable runnable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+
+  LoggingContextAwareRunnable(Runnable runnable) {
+    this.runnable = runnable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+  }
+
+  public Runnable unwrap() {
+    return runnable;
+  }
+
+  @Override
+  public void run() {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      runnable.run();
+      return;
+    }
+
+    // propagate logging context
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
+    boolean oldForceLogging = loggingCtx.isLoggingForced();
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    try {
+      runnable.run();
+    } finally {
+      loggingCtx.setTags(oldTags);
+      loggingCtx.forceLogging(oldForceLogging);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java b/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java
new file mode 100644
index 0000000..e17a91e
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2018 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.logging;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A {@link ScheduledExecutorService} that copies the {@link LoggingContext} on executing a {@link
+ * Runnable} to the executing thread.
+ */
+public class LoggingContextAwareScheduledExecutorService extends LoggingContextAwareExecutorService
+    implements ScheduledExecutorService {
+  private final ScheduledExecutorService scheduledExecutorService;
+
+  public LoggingContextAwareScheduledExecutorService(
+      ScheduledExecutorService scheduledExecutorService) {
+    super(scheduledExecutorService);
+    this.scheduledExecutorService = scheduledExecutorService;
+  }
+
+  @Override
+  public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+    return scheduledExecutorService.schedule(LoggingContext.copy(command), delay, unit);
+  }
+
+  @Override
+  public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+    return scheduledExecutorService.schedule(LoggingContext.copy(callable), delay, unit);
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleAtFixedRate(
+      Runnable command, long initialDelay, long period, TimeUnit unit) {
+    return scheduledExecutorService.scheduleAtFixedRate(
+        LoggingContext.copy(command), initialDelay, period, unit);
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleWithFixedDelay(
+      Runnable command, long initialDelay, long delay, TimeUnit unit) {
+    return scheduledExecutorService.scheduleWithFixedDelay(
+        LoggingContext.copy(command), initialDelay, delay, unit);
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
new file mode 100644
index 0000000..a936a43
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2018 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.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.backend.Tags;
+
+public class MutableTags {
+  private final SetMultimap<String, String> tagMap =
+      MultimapBuilder.hashKeys().hashSetValues().build();
+  private Tags tags = Tags.empty();
+
+  public Tags getTags() {
+    return tags;
+  }
+
+  /**
+   * Adds a tag if a tag with the same name and value doesn't exist yet.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   * @return {@code true} if the tag was added, {@code false} if the tag was not added because it
+   *     already exists
+   */
+  public boolean add(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    boolean ret = tagMap.put(name, value);
+    if (ret) {
+      buildTags();
+    }
+    return ret;
+  }
+
+  /**
+   * Removes the tag with the given name and value.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   */
+  public void remove(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    if (tagMap.remove(name, value)) {
+      buildTags();
+    }
+  }
+
+  /**
+   * Checks if the contained tag map is empty.
+   *
+   * @return {@code true} if there are no tags, otherwise {@code false}
+   */
+  public boolean isEmpty() {
+    return tagMap.isEmpty();
+  }
+
+  /** Clears all tags. */
+  public void clear() {
+    tagMap.clear();
+    tags = Tags.empty();
+  }
+
+  /**
+   * Returns the tags as Multimap.
+   *
+   * @return the tags as Multimap
+   */
+  public ImmutableSetMultimap<String, String> asMap() {
+    return ImmutableSetMultimap.copyOf(tagMap);
+  }
+
+  /**
+   * Replaces the existing tags with the provided tags.
+   *
+   * @param tags the tags that should be set.
+   */
+  void set(ImmutableSetMultimap<String, String> tags) {
+    tagMap.clear();
+    tags.forEach(tagMap::put);
+    buildTags();
+  }
+
+  private void buildTags() {
+    if (tagMap.isEmpty()) {
+      if (tags.isEmpty()) {
+        return;
+      }
+      tags = Tags.empty();
+      return;
+    }
+
+    Tags.Builder tagsBuilder = Tags.builder();
+    tagMap.forEach(tagsBuilder::addTag);
+    tags = tagsBuilder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
similarity index 70%
rename from java/com/google/gerrit/server/util/RequestId.java
rename to java/com/google/gerrit/server/logging/RequestId.java
index 8e8db12..b0a8ad9 100644
--- a/java/com/google/gerrit/server/util/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.logging;
 
+import com.google.common.base.Enums;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 
@@ -36,27 +36,34 @@
     MACHINE_ID = id;
   }
 
-  public static RequestId forChange(Change c) {
-    return new RequestId(c.getId().toString());
+  public enum Type {
+    RECEIVE_ID,
+    SUBMISSION_ID,
+    TRACE_ID;
+
+    static boolean isId(String id) {
+      return id != null && Enums.getIfPresent(Type.class, id).isPresent();
+    }
   }
 
-  public static RequestId forProject(Project.NameKey p) {
-    return new RequestId(p.toString());
+  public static boolean isSet() {
+    return LoggingContext.getInstance().getTagsAsMap().keySet().stream().anyMatch(Type::isId);
   }
 
   private final String str;
 
-  private RequestId(String resourceId) {
+  public RequestId() {
+    this(null);
+  }
+
+  public RequestId(@Nullable String resourceId) {
     Hasher h = Hashing.murmur3_128().newHasher();
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
-        "["
-            + resourceId
-            + "-"
+        (resourceId != null ? resourceId + "-" : "")
             + TimeUtil.nowTs().getTime()
             + "-"
-            + h.hash().toString().substring(0, 8)
-            + "]";
+            + h.hash().toString().substring(0, 8);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
new file mode 100644
index 0000000..977baa5
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -0,0 +1,263 @@
+// Copyright (C) 2018 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.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Strings;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * TraceContext that allows to set logging tags and enforce logging.
+ *
+ * <p>The logging tags are attached to all log entries that are triggered while the trace context is
+ * open. If force logging is enabled all logs that are triggered while the trace context is open are
+ * written to the log file regardless of the configured log level.
+ *
+ * <pre>
+ * try (TraceContext traceContext = TraceContext.open()
+ *         .addTag("tag-name", "tag-value")
+ *         .forceLogging()) {
+ *     // This gets logged as: A log [CONTEXT forced=true tag-name="tag-value" ]
+ *     // Since force logging is enabled this gets logged independently of the configured log
+ *     // level.
+ *     logger.atFinest().log("A log");
+ *
+ *     // do stuff
+ * }
+ * </pre>
+ *
+ * <p>The logging tags and the force logging flag are stored in the {@link LoggingContext}. {@link
+ * LoggingContextAwareExecutorService}, {@link LoggingContextAwareScheduledExecutorService} and the
+ * executor in {@link com.google.gerrit.server.git.WorkQueue} ensure that the logging context is
+ * automatically copied to background threads.
+ *
+ * <p>On close of the trace context newly set tags are unset. Force logging is disabled on close if
+ * it got enabled while the trace context was open.
+ *
+ * <p>Trace contexts can be nested:
+ *
+ * <pre>
+ * // Initially there are no tags
+ * logger.atSevere().log("log without tag");
+ *
+ * // a tag can be set by opening a trace context
+ * try (TraceContext ctx = TraceContext.open().addTag("tag1", "value1")) {
+ *   logger.atSevere().log("log with tag1=value1");
+ *
+ *   // while a trace context is open further tags can be added.
+ *   ctx.addTag("tag2", "value2")
+ *   logger.atSevere().log("log with tag1=value1 and tag2=value2");
+ *
+ *   // also by opening another trace context a another tag can be added
+ *   try (TraceContext ctx2 = TraceContext.open().addTag("tag3", "value3")) {
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2 and tag3=value3");
+ *
+ *     // it's possible to have the same tag name with multiple values
+ *     ctx2.addTag("tag3", "value3a")
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *
+ *     // adding a tag with the same name and value as an existing tag has no effect
+ *     try (TraceContext ctx3 = TraceContext.open().addTag("tag3", "value3a")) {
+ *       logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *     }
+ *
+ *     // closing ctx3 didn't remove tag3=value3a since it was already set before opening ctx3
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *   }
+ *
+ *   // closing ctx2 removed tag3=value3 and tag3-value3a
+ *   logger.atSevere().log("with tag1=value1 and tag2=value2");
+ * }
+ *
+ * // closing ctx1 removed tag1=value1 and tag2=value2
+ * logger.atSevere().log("log without tag");
+ * </pre>
+ */
+public class TraceContext implements AutoCloseable {
+  public static TraceContext open() {
+    return new TraceContext();
+  }
+
+  /**
+   * Opens a new trace context for request tracing.
+   *
+   * <ul>
+   *   <li>sets a tag with a trace ID
+   *   <li>enables force logging
+   * </ul>
+   *
+   * <p>if no trace ID is provided a new trace ID is only generated if request tracing was not
+   * started yet. If request tracing was already started the given {@code traceIdConsumer} is
+   * invoked with the existing trace ID and no new logging tag is set.
+   *
+   * <p>No-op if {@code trace} is {@code false}.
+   *
+   * @param trace whether tracing should be started
+   * @param traceId trace ID that should be used for tracing, if {@code null} a trace ID is
+   *     generated
+   * @param traceIdConsumer consumer for the trace ID, should be used to return the generated trace
+   *     ID to the client, not invoked if {@code trace} is {@code false}
+   * @return the trace context
+   */
+  public static TraceContext newTrace(
+      boolean trace, @Nullable String traceId, TraceIdConsumer traceIdConsumer) {
+    if (!trace) {
+      // Create an empty trace context.
+      return open();
+    }
+
+    if (!Strings.isNullOrEmpty(traceId)) {
+      traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), traceId);
+      return open().addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
+    }
+
+    Optional<String> existingTraceId =
+        LoggingContext.getInstance()
+            .getTagsAsMap()
+            .get(RequestId.Type.TRACE_ID.name())
+            .stream()
+            .findAny();
+    if (existingTraceId.isPresent()) {
+      // request tracing was already started, no need to generate a new trace ID
+      traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), existingTraceId.get());
+      return open();
+    }
+
+    RequestId newTraceId = new RequestId();
+    traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), newTraceId.toString());
+    return open().addTag(RequestId.Type.TRACE_ID, newTraceId).forceLogging();
+  }
+
+  @FunctionalInterface
+  public interface TraceIdConsumer {
+    void accept(String tagName, String traceId);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param message the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String message) {
+    return new TraceTimer(message);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param format the message format string
+   * @param arg argument for the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String format, Object arg) {
+    return new TraceTimer(format, arg);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param format the message format string
+   * @param arg1 first argument for the message
+   * @param arg2 second argument for the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String format, Object arg1, Object arg2) {
+    return new TraceTimer(format, arg1, arg2);
+  }
+
+  public static class TraceTimer implements AutoCloseable {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+    private final Consumer<Long> logFn;
+    private final Stopwatch stopwatch;
+
+    private TraceTimer(String message) {
+      this(elapsedMs -> logger.atFine().log(message + " (%d ms)", elapsedMs));
+    }
+
+    private TraceTimer(String format, @Nullable Object arg) {
+      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg, elapsedMs));
+    }
+
+    private TraceTimer(String format, @Nullable Object arg1, @Nullable Object arg2) {
+      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, elapsedMs));
+    }
+
+    private TraceTimer(Consumer<Long> logFn) {
+      this.logFn = logFn;
+      this.stopwatch = Stopwatch.createStarted();
+    }
+
+    @Override
+    public void close() {
+      stopwatch.stop();
+      logFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+    }
+  }
+
+  // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
+  private final Table<String, String, Boolean> tags = HashBasedTable.create();
+
+  private boolean stopForceLoggingOnClose;
+
+  private TraceContext() {}
+
+  public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
+    return addTag(checkNotNull(requestId, "request ID is required").name(), tagValue);
+  }
+
+  public TraceContext addTag(String tagName, Object tagValue) {
+    String name = checkNotNull(tagName, "tag name is required");
+    String value = checkNotNull(tagValue, "tag value is required").toString();
+    tags.put(name, value, LoggingContext.getInstance().addTag(name, value));
+    return this;
+  }
+
+  public TraceContext forceLogging() {
+    if (stopForceLoggingOnClose) {
+      return this;
+    }
+
+    stopForceLoggingOnClose = !LoggingContext.getInstance().forceLogging(true);
+    return this;
+  }
+
+  @Override
+  public void close() {
+    for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
+      if (cell.getValue()) {
+        LoggingContext.getInstance().removeTag(cell.getRowKey(), cell.getColumnKey());
+      }
+    }
+    if (stopForceLoggingOnClose) {
+      LoggingContext.getInstance().forceLogging(false);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
index 9032932..e18fd42 100644
--- a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
+++ b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.server.mail;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.mail.MailMessage;
 import com.google.inject.Singleton;
 
 /** Filters out auto-reply messages according to RFC 3834. */
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 5a41c77..1549f8d 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -17,8 +17,8 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.receive.MailMessage;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Arrays;
@@ -52,8 +52,9 @@
       return true;
     }
 
-    boolean match = mailPattern.matcher(message.from().email).find();
-    if (mode == ListFilterMode.WHITELIST && !match || mode == ListFilterMode.BLACKLIST && match) {
+    boolean match = mailPattern.matcher(message.from().getEmail()).find();
+    if ((mode == ListFilterMode.WHITELIST && !match)
+        || (mode == ListFilterMode.BLACKLIST && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
       return false;
     }
diff --git a/java/com/google/gerrit/server/mail/MailFilter.java b/java/com/google/gerrit/server/mail/MailFilter.java
index d50064d..5fff8a3 100644
--- a/java/com/google/gerrit/server/mail/MailFilter.java
+++ b/java/com/google/gerrit/server/mail/MailFilter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 
 /**
  * Listener to filter incoming email.
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index 0487cc0..507b53f 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
-import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -34,8 +33,6 @@
 import org.eclipse.jgit.revwalk.FooterLine;
 
 public class MailUtil {
-  public static DateTimeFormatter rfcDateformatter =
-      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   public static MailRecipients getRecipientsFromFooters(
       AccountResolver accountResolver, List<FooterLine> footerLines)
diff --git a/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
index 169b41e..648006d 100644
--- a/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.mail.receive;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailParsingException;
+import com.google.gerrit.mail.RawMailParser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0e0bca6..707056c 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -26,6 +26,12 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.mail.HtmlParser;
+import com.google.gerrit.mail.MailComment;
+import com.google.gerrit.mail.MailHeaderParser;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailMetadata;
+import com.google.gerrit.mail.TextParser;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -231,7 +237,7 @@
               .sorted(CommentsUtil.COMMENT_ORDER)
               .collect(toList());
       Project.NameKey project = cd.project();
-      String changeUrl = canonicalUrl.get() + "#/c/" + cd.getId().get();
+      String changeUrl = canonicalUrl.get() + "c/" + cd.project().get() + "/+/" + cd.getId().get();
 
       List<MailComment> parsedComments;
       if (useHtmlParser(message)) {
@@ -283,7 +289,7 @@
 
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
-        if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
+        if (c.getType() == MailComment.CommentType.CHANGE_MESSAGE) {
           continue;
         }
         comments.add(
@@ -301,8 +307,8 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       String patchSetComment = null;
-      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
-        patchSetComment = parsedComments.get(0).message;
+      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
+        patchSetComment = parsedComments.get(0).getMessage();
       }
       // Send email notifications
       outgoingMailFactory
@@ -323,7 +329,6 @@
           .byPatchSetUser(
               ctx.getDb(),
               notes,
-              ctx.getUser(),
               psId,
               ctx.getAccountId(),
               ctx.getRevWalk(),
@@ -343,12 +348,12 @@
 
     private ChangeMessage generateChangeMessage(ChangeContext ctx) {
       String changeMsg = "Patch Set " + psId.get() + ":";
-      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
+      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         // Add a blank line after Patch Set to follow the default format
         if (parsedComments.size() > 1) {
           changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
         }
-        changeMsg += "\n\n" + parsedComments.get(0).message;
+        changeMsg += "\n\n" + parsedComments.get(0).getMessage();
       } else {
         changeMsg += "\n\n" + numComments(parsedComments.size());
       }
@@ -357,11 +362,11 @@
 
     private PatchSet targetPatchSetForComment(
         ChangeContext ctx, MailComment mailComment, PatchSet current) throws OrmException {
-      if (mailComment.inReplyTo != null) {
+      if (mailComment.getInReplyTo() != null) {
         return psUtil.get(
             ctx.getDb(),
             ctx.getNotes(),
-            new PatchSet.Id(ctx.getChange().getId(), mailComment.inReplyTo.key.patchSetId));
+            new PatchSet.Id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
       }
       return current;
     }
@@ -373,11 +378,11 @@
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
       Side side;
-      if (mailComment.inReplyTo != null) {
-        fileName = mailComment.inReplyTo.key.filename;
-        side = Side.fromShort(mailComment.inReplyTo.side);
+      if (mailComment.getInReplyTo() != null) {
+        fileName = mailComment.getInReplyTo().key.filename;
+        side = Side.fromShort(mailComment.getInReplyTo().side);
       } else {
-        fileName = mailComment.fileName;
+        fileName = mailComment.getFileName();
         side = Side.REVISION;
       }
 
@@ -387,16 +392,16 @@
               fileName,
               patchSetForComment.getId(),
               (short) side.ordinal(),
-              mailComment.message,
+              mailComment.getMessage(),
               false,
               null);
 
       comment.tag = tag;
-      if (mailComment.inReplyTo != null) {
-        comment.parentUuid = mailComment.inReplyTo.key.uuid;
-        comment.lineNbr = mailComment.inReplyTo.lineNbr;
-        comment.range = mailComment.inReplyTo.range;
-        comment.unresolved = mailComment.inReplyTo.unresolved;
+      if (mailComment.getInReplyTo() != null) {
+        comment.parentUuid = mailComment.getInReplyTo().key.uuid;
+        comment.lineNbr = mailComment.getInReplyTo().lineNbr;
+        comment.range = mailComment.getInReplyTo().range;
+        comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
       CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), patchSetForComment);
       return comment;
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index e4ad969..dc99b46 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.update.UpdateException;
diff --git a/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
index a3ea265..54971c4 100644
--- a/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
@@ -16,6 +16,9 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailParsingException;
+import com.google.gerrit.mail.RawMailParser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index ae8ac31..433bd9b 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -18,9 +18,9 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 503fbd0..4ba3b67 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -30,7 +31,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -221,10 +221,7 @@
   /** Get a link to the change; null if the server doesn't know its own address. */
   public String getChangeUrl() {
     if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append(change.getChangeId());
-      return r.toString();
+      return getGerritUrl() + "c/" + change.getProject().get() + "/+/" + change.getChangeId();
     }
     return null;
   }
@@ -403,12 +400,19 @@
 
   @Override
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
-    return projectState.statePermitsRead()
-        && args.permissionBackend
-            .absentUser(to)
-            .change(changeData)
-            .database(args.db)
-            .test(ChangePermission.READ);
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    try {
+      args.permissionBackend
+          .absentUser(to)
+          .change(changeData)
+          .database(args.db)
+          .check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   /** Find all users who are authors of any part of this change. */
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 0095fc1..0baaa11c 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -23,6 +23,8 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -32,8 +34,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
@@ -239,7 +239,7 @@
       }
     }
 
-    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    groups.sort(Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
     return groups;
   }
 
@@ -566,7 +566,7 @@
 
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
-    return MailUtil.rfcDateformatter.format(
+    return MailProcessingUtil.rfcDateformatter.format(
         ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index c434d06..576d506 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 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.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 04f4d6c..f49951f 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -49,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+@UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
   final GitRepositoryManager server;
   final ProjectCache projectCache;
diff --git a/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
index 23fa1fe..ce4964d 100644
--- a/java/com/google/gerrit/server/mail/send/EmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -16,7 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import java.util.Collection;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 2489063..5baabe9 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index 500eef3..b77909e 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -17,13 +17,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.MailUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 5143dc7..65ce128 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -18,8 +18,8 @@
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailHeader;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import org.apache.james.mime4j.dom.field.FieldName;
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cf9257c..34f959c 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -70,12 +70,7 @@
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca :
           args.approvalsUtil.byPatchSet(
-              args.db.get(),
-              changeData.notes(),
-              args.identifiedUserFactory.create(changeData.change().getOwner()),
-              patchSet.getId(),
-              null,
-              null)) {
+              args.db.get(), changeData.notes(), patchSet.getId(), null, null)) {
         LabelType lt = labelTypes.byLabel(ca.getLabelId());
         if (lt == null) {
           continue;
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 9f94fa3..f94f1ca 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 0cc7a1d..032bcbf 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -19,11 +19,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gwtorm.server.OrmException;
 import java.util.HashMap;
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index a62a910..fe9b446 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -26,12 +26,13 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.EmailHeader.AddressList;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
@@ -356,7 +357,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  public String getNameEmailFor(Account.Id accountId) {
+  protected String getNameEmailFor(Account.Id accountId) {
     Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount);
     if (account.isPresent()) {
       String name = account.get().getFullName();
@@ -379,7 +380,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, username, or null if unset.
    */
-  public String getUserNameEmailFor(Account.Id accountId) {
+  protected String getUserNameEmailFor(Account.Id accountId) {
     Optional<AccountState> accountState = args.accountCache.get(accountId);
     if (!accountState.isPresent()) {
       return null;
@@ -564,28 +565,6 @@
     return soyTemplate(name, SanitizedContent.ContentKind.HTML);
   }
 
-  public String joinStrings(Iterable<Object> in, String joiner) {
-    return joinStrings(in.iterator(), joiner);
-  }
-
-  public String joinStrings(Iterator<Object> in, String joiner) {
-    if (!in.hasNext()) {
-      return "";
-    }
-
-    Object first = in.next();
-    if (!in.hasNext()) {
-      return safeToString(first);
-    }
-
-    StringBuilder r = new StringBuilder();
-    r.append(safeToString(first));
-    while (in.hasNext()) {
-      r.append(joiner).append(safeToString(in.next()));
-    }
-    return r.toString();
-  }
-
   protected void removeUser(Account user) {
     String fromEmail = user.getPreferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
@@ -601,10 +580,6 @@
     }
   }
 
-  private static String safeToString(Object obj) {
-    return obj != null ? obj.toString() : "";
-  }
-
   protected final boolean useHtml() {
     return args.settings.html && supportsHtml();
   }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 15197ef..9a86fdb 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
@@ -30,7 +31,6 @@
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index c667026..0dbfb60 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -18,8 +18,8 @@
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 10e8b0b..2262b5c 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -22,9 +22,10 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.Encryption;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/mime/MimeUtil2Module.java b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
index 387482a..7fdc4fb 100644
--- a/java/com/google/gerrit/server/mime/MimeUtil2Module.java
+++ b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mime;
 
-import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index eecf935..0e9a2b7 100644
--- a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mime;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -22,12 +24,9 @@
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.InputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
@@ -115,16 +114,7 @@
       return MimeUtil2.UNKNOWN_MIME_TYPE;
     }
 
-    final List<MimeType> types = new ArrayList<>(mimeTypes);
-    Collections.sort(
-        types,
-        new Comparator<MimeType>() {
-          @Override
-          public int compare(MimeType a, MimeType b) {
-            return getCorrectedMimeSpecificity(b) - getCorrectedMimeSpecificity(a);
-          }
-        });
-    return types.get(0);
+    return Collections.max(mimeTypes, comparing(this::getCorrectedMimeSpecificity));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index a083a71..632f6fc 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -145,6 +145,7 @@
     if (loaded) {
       return self();
     }
+
     boolean read = args.migration.readChanges();
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 3653bc7..e0cc771 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -41,6 +42,8 @@
 
 /** A single delta related to a specific patch-set of a change. */
 public abstract class AbstractChangeUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final NotesMigration migration;
   protected final ChangeNoteUtil noteUtil;
   protected final Account.Id accountId;
@@ -120,9 +123,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil
-          .getLegacyChangeNoteWrite()
-          .newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
@@ -177,7 +178,7 @@
   }
 
   protected PersonIdent newIdent(Account.Id authorId, Date when) {
-    return noteUtil.getLegacyChangeNoteWrite().newIdent(authorId, when, serverIdent);
+    return noteUtil.newIdent(authorId, when, serverIdent);
   }
 
   /** Whether no updates have been done. */
@@ -220,6 +221,11 @@
 
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     checkNotReadOnly();
+
+    logger.atFinest().log(
+        "%s for change %s of project %s in %s (NoteDb)",
+        getClass().getSimpleName(), getId(), getProjectName(), getRefName());
+
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
     if (cb == null) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
index 1d3c752..0ebee1a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -17,11 +17,16 @@
 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.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
 import static com.google.gerrit.common.TimeUtil.truncateToSecond;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsFirst;
 import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
@@ -31,7 +36,6 @@
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -43,13 +47,14 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 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.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 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.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -70,7 +75,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TreeMap;
 
 /**
  * A bundle of all entities rooted at a single {@link Change} entity.
@@ -105,110 +109,65 @@
         Source.NOTE_DB);
   }
 
-  private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
-      Iterable<ChangeMessage> in) {
-    Map<ChangeMessage.Key, ChangeMessage> out =
-        new TreeMap<>(
-            new Comparator<ChangeMessage.Key>() {
-              @Override
-              public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
-                return ComparisonChain.start()
-                    .compare(a.getParentKey().get(), b.getParentKey().get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (ChangeMessage cm : in) {
-      out.put(cm.getKey(), cm);
-    }
-    return out;
+  private static ImmutableSortedMap<ChangeMessage.Key, ChangeMessage> changeMessageMap(
+      Collection<ChangeMessage> in) {
+    return in.stream()
+        .collect(
+            toImmutableSortedMap(
+                comparing((ChangeMessage.Key k) -> k.getParentKey().get())
+                    .thenComparing(k -> k.get()),
+                cm -> cm.getKey(),
+                cm -> cm));
   }
 
   // Unlike the *Map comparators, which are intended to make key lists diffable,
   // this comparator sorts first on timestamp, then on every other field.
-  private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
-      new Ordering<ChangeMessage>() {
-        final Ordering<Comparable<?>> nullsFirst = Ordering.natural().nullsFirst();
-
-        @Override
-        public int compare(ChangeMessage a, ChangeMessage b) {
-          return ComparisonChain.start()
-              .compare(a.getWrittenOn(), b.getWrittenOn())
-              .compare(a.getKey().getParentKey().get(), b.getKey().getParentKey().get())
-              .compare(psId(a), psId(b), nullsFirst)
-              .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
-              .compare(a.getMessage(), b.getMessage(), nullsFirst)
-              .result();
-        }
-
-        private Integer psId(ChangeMessage m) {
-          return m.getPatchSetId() != null ? m.getPatchSetId().get() : null;
-        }
-      };
+  private static final Comparator<ChangeMessage> CHANGE_MESSAGE_COMPARATOR =
+      comparing(ChangeMessage::getWrittenOn)
+          .thenComparing(m -> m.getKey().getParentKey().get())
+          .thenComparing(
+              m -> m.getPatchSetId() != null ? m.getPatchSetId().get() : null,
+              nullsFirst(naturalOrder()))
+          .thenComparing(ChangeMessage::getAuthor, intKeyOrdering())
+          .thenComparing(ChangeMessage::getMessage, nullsFirst(naturalOrder()));
 
   private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
-    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
+    return Streams.stream(in).sorted(CHANGE_MESSAGE_COMPARATOR).collect(toImmutableList());
   }
 
-  private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
-    TreeMap<PatchSet.Id, PatchSet> out =
-        new TreeMap<>(
-            new Comparator<PatchSet.Id>() {
-              @Override
-              public int compare(PatchSet.Id a, PatchSet.Id b) {
-                return patchSetIdChain(a, b).result();
-              }
-            });
-    for (PatchSet ps : in) {
-      out.put(ps.getId(), ps);
-    }
-    return out;
+  private static ImmutableSortedMap<Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
+    return Streams.stream(in)
+        .collect(toImmutableSortedMap(patchSetIdComparator(), PatchSet::getId, ps -> ps));
   }
 
-  private static Map<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
+  private static ImmutableSortedMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
       Iterable<PatchSetApproval> in) {
-    Map<PatchSetApproval.Key, PatchSetApproval> out =
-        new TreeMap<>(
-            new Comparator<PatchSetApproval.Key>() {
-              @Override
-              public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
-                return patchSetIdChain(a.getParentKey(), b.getParentKey())
-                    .compare(a.getAccountId().get(), b.getAccountId().get())
-                    .compare(a.getLabelId(), b.getLabelId())
-                    .result();
-              }
-            });
-    for (PatchSetApproval psa : in) {
-      out.put(psa.getKey(), psa);
-    }
-    return out;
+    return Streams.stream(in)
+        .collect(
+            toImmutableSortedMap(
+                comparing(PatchSetApproval.Key::getParentKey, patchSetIdComparator())
+                    .thenComparing(PatchSetApproval.Key::getAccountId, intKeyOrdering())
+                    .thenComparing(PatchSetApproval.Key::getLabelId),
+                PatchSetApproval::getKey,
+                a -> a));
   }
 
-  private static Map<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
+  private static ImmutableSortedMap<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
       Iterable<PatchLineComment> in) {
-    Map<PatchLineComment.Key, PatchLineComment> out =
-        new TreeMap<>(
-            new Comparator<PatchLineComment.Key>() {
-              @Override
-              public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
-                Patch.Key pka = a.getParentKey();
-                Patch.Key pkb = b.getParentKey();
-                return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
-                    .compare(pka.get(), pkb.get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (PatchLineComment plc : in) {
-      out.put(plc.getKey(), plc);
-    }
-    return out;
+    return Streams.stream(in)
+        .collect(
+            toImmutableSortedMap(
+                comparing(
+                        (PatchLineComment.Key k) -> k.getParentKey().getParentKey(),
+                        patchSetIdComparator())
+                    .thenComparing(PatchLineComment.Key::getParentKey)
+                    .thenComparing(PatchLineComment.Key::get),
+                PatchLineComment::getKey,
+                c -> c));
   }
 
-  private static ComparisonChain patchSetIdChain(PatchSet.Id a, PatchSet.Id b) {
-    return ComparisonChain.start()
-        .compare(a.getParentKey().get(), b.getParentKey().get())
-        .compare(a.get(), b.get());
+  private static Comparator<PatchSet.Id> patchSetIdComparator() {
+    return comparing((PatchSet.Id id) -> id.getParentKey().get()).thenComparing(id -> id.get());
   }
 
   static {
@@ -436,7 +395,7 @@
       excludeOrigSubj = true;
       String aTopic = trimOrNull(a.getTopic());
       excludeTopic =
-          Objects.equals(aTopic, b.getTopic()) || "".equals(aTopic) && b.getTopic() == null;
+          Objects.equals(aTopic, b.getTopic()) || ("".equals(aTopic) && b.getTopic() == null);
       aUpdated = bundleA.getLatestTimestamp();
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
       boolean createdOnMatchesFirstPs =
@@ -454,7 +413,7 @@
       excludeOrigSubj = true;
       String bTopic = trimOrNull(b.getTopic());
       excludeTopic =
-          Objects.equals(bTopic, a.getTopic()) || a.getTopic() == null && "".equals(bTopic);
+          Objects.equals(bTopic, a.getTopic()) || (a.getTopic() == null && "".equals(bTopic));
       bUpdated = bundleB.getLatestTimestamp();
     }
 
@@ -598,9 +557,10 @@
     }
     if (!bs.isEmpty()) {
       sb.append("Only in B:");
-      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
-        sb.append("\n  ").append(cm);
-      }
+      bs.values()
+          .stream()
+          .sorted(CHANGE_MESSAGE_COMPARATOR)
+          .forEach(cm -> sb.append("\n  ").append(cm));
     }
     diffs.add(sb.toString());
   }
@@ -758,7 +718,8 @@
         excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
       } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
         excludeGranted =
-            tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()) || tb.compareTo(ta) < 0;
+            (tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()))
+                || (tb.compareTo(ta) < 0);
         excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
       }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundleReader.java b/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
index 9e7a1fe1..3207c3b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
+++ b/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.server.OrmException;
 
 public interface ChangeBundleReader {
+  @Nullable
   ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException;
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 6b4bea7..169fd2e 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -178,12 +178,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data =
-          e.getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+      byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 0475fe3..483b2e9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,18 +14,14 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
-  private final boolean writeJson;
 
   static Gson newGson() {
     return new GsonBuilder()
@@ -37,13 +33,4 @@
   public Gson getGson() {
     return gson;
   }
-
-  public boolean getWriteJson() {
-    return writeJson;
-  }
-
-  @Inject
-  ChangeNoteJson(@GerritServerConfig Config config) {
-    this.writeJson = config.getBoolean("notedb", "writeJson", true);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 070f974..8a0cabe 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -14,8 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
+import java.util.Date;
+import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.RawParseUtils;
 
 public class ChangeNoteUtil {
   public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
@@ -56,17 +65,17 @@
   static final String TAG = FOOTER_TAG.getName();
 
   private final LegacyChangeNoteRead legacyChangeNoteRead;
-  private final LegacyChangeNoteWrite legacyChangeNoteWrite;
   private final ChangeNoteJson changeNoteJson;
+  private final String serverId;
 
   @Inject
   public ChangeNoteUtil(
       ChangeNoteJson changeNoteJson,
       LegacyChangeNoteRead legacyChangeNoteRead,
-      LegacyChangeNoteWrite legacyChangeNoteWrite) {
+      @GerritServerId String serverId) {
+    this.serverId = serverId;
     this.changeNoteJson = changeNoteJson;
     this.legacyChangeNoteRead = legacyChangeNoteRead;
-    this.legacyChangeNoteWrite = legacyChangeNoteWrite;
   }
 
   public LegacyChangeNoteRead getLegacyChangeNoteRead() {
@@ -77,7 +86,103 @@
     return changeNoteJson;
   }
 
-  public LegacyChangeNoteWrite getLegacyChangeNoteWrite() {
-    return legacyChangeNoteWrite;
+  public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        "Gerrit User " + authorId.toString(),
+        authorId.get() + "@" + serverId,
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  @VisibleForTesting
+  public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        "Gerrit User " + author.getId(),
+        author.getId().get() + "@" + serverId,
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  public static Optional<CommitMessageRange> parseCommitMessageRange(RevCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+    int size = raw.length;
+
+    int subjectStart = RawParseUtils.commitMessage(raw, 0);
+    if (subjectStart < 0 || subjectStart >= size) {
+      return Optional.empty();
+    }
+
+    int subjectEnd = RawParseUtils.endOfParagraph(raw, subjectStart);
+    if (subjectEnd == size) {
+      return Optional.empty();
+    }
+
+    int changeMessageStart;
+
+    if (raw[subjectEnd] == '\n') {
+      changeMessageStart = subjectEnd + 2; // \n\n ends paragraph
+    } else if (raw[subjectEnd] == '\r') {
+      changeMessageStart = subjectEnd + 4; // \r\n\r\n ends paragraph
+    } else {
+      return Optional.empty();
+    }
+
+    int ptr = size - 1;
+    int changeMessageEnd = -1;
+    while (ptr > changeMessageStart) {
+      ptr = RawParseUtils.prevLF(raw, ptr, '\r');
+      if (ptr == -1) {
+        break;
+      }
+      if (raw[ptr] == '\n') {
+        changeMessageEnd = ptr - 1;
+        break;
+      } else if (raw[ptr] == '\r') {
+        changeMessageEnd = ptr - 3;
+        break;
+      }
+    }
+
+    if (ptr <= changeMessageStart) {
+      return Optional.empty();
+    }
+
+    CommitMessageRange range =
+        CommitMessageRange.builder()
+            .subjectStart(subjectStart)
+            .subjectEnd(subjectEnd)
+            .changeMessageStart(changeMessageStart)
+            .changeMessageEnd(changeMessageEnd)
+            .build();
+
+    return Optional.of(range);
+  }
+
+  @AutoValue
+  public abstract static class CommitMessageRange {
+    public abstract int subjectStart();
+
+    public abstract int subjectEnd();
+
+    public abstract int changeMessageStart();
+
+    public abstract int changeMessageEnd();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNoteUtil_CommitMessageRange.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder subjectStart(int subjectStart);
+
+      abstract Builder subjectEnd(int subjectEnd);
+
+      abstract Builder changeMessageStart(int changeMessageStart);
+
+      abstract Builder changeMessageEnd(int changeMessageEnd);
+
+      abstract CommitMessageRange build();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 7e66d929..d257118 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -439,7 +439,7 @@
     private static ScanResult scanChangeIds(Repository repo) throws IOException {
       ImmutableSet.Builder<Change.Id> fromPs = ImmutableSet.builder();
       ImmutableSet.Builder<Change.Id> fromMeta = ImmutableSet.builder();
-      for (Ref r : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+      for (Ref r : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
         Change.Id id = Change.Id.fromRef(r.getName());
         if (id != null) {
           (r.getName().endsWith(RefNames.META_SUFFIX) ? fromMeta : fromPs).add(id);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 0bf2108..cc316e5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -18,6 +18,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -25,10 +26,10 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.inject.Inject;
@@ -46,6 +47,8 @@
 
 @Singleton
 public class ChangeNotesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @VisibleForTesting static final String CACHE_NAME = "change_notes";
 
   public static Module module() {
@@ -77,7 +80,7 @@
     abstract ObjectId id();
 
     @VisibleForTesting
-    static enum Serializer implements CacheSerializer<Key> {
+    enum Serializer implements CacheSerializer<Key> {
       INSTANCE;
 
       @Override
@@ -345,6 +348,8 @@
 
     @Override
     public ChangeNotesState call() throws ConfigInvalidException, IOException {
+      logger.atFine().log(
+          "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
               key.changeId(),
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5f2593b..cbb7020 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -36,6 +36,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.stream.Collectors.joining;
 
@@ -55,6 +56,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -70,7 +72,6 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -78,7 +79,6 @@
 import java.sql.Timestamp;
 import java.text.ParseException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -296,9 +296,7 @@
       }
       result.put(a.getPatchSetId(), a);
     }
-    for (Collection<PatchSetApproval> v : result.asMap().values()) {
-      Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
-    }
+    result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
@@ -692,61 +690,34 @@
       Account.Id realAccountId,
       ChangeNotesCommit commit,
       Timestamp ts) {
-    byte[] raw = commit.getRawBuffer();
-    int size = raw.length;
-    Charset enc = RawParseUtils.parseEncoding(raw);
-
-    int subjectStart = RawParseUtils.commitMessage(raw, 0);
-    if (subjectStart < 0 || subjectStart >= size) {
+    Optional<String> changeMsgString = getChangeMessageString(commit);
+    if (!changeMsgString.isPresent()) {
       return;
     }
 
-    int subjectEnd = RawParseUtils.endOfParagraph(raw, subjectStart);
-    if (subjectEnd == size) {
-      return;
-    }
-
-    int changeMessageStart;
-
-    if (raw[subjectEnd] == '\n') {
-      changeMessageStart = subjectEnd + 2; // \n\n ends paragraph
-    } else if (raw[subjectEnd] == '\r') {
-      changeMessageStart = subjectEnd + 4; // \r\n\r\n ends paragraph
-    } else {
-      return;
-    }
-
-    int ptr = size - 1;
-    int changeMessageEnd = -1;
-    while (ptr > changeMessageStart) {
-      ptr = RawParseUtils.prevLF(raw, ptr, '\r');
-      if (ptr == -1) {
-        break;
-      }
-      if (raw[ptr] == '\n') {
-        changeMessageEnd = ptr - 1;
-        break;
-      } else if (raw[ptr] == '\r') {
-        changeMessageEnd = ptr - 3;
-        break;
-      }
-    }
-
-    if (ptr <= changeMessageStart) {
-      return;
-    }
-
-    String changeMsgString =
-        RawParseUtils.decode(enc, raw, changeMessageStart, changeMessageEnd + 1);
     ChangeMessage changeMessage =
         new ChangeMessage(
             new ChangeMessage.Key(psId.getParentKey(), commit.name()), accountId, ts, psId);
-    changeMessage.setMessage(changeMsgString);
+    changeMessage.setMessage(changeMsgString.get());
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
     allChangeMessages.add(changeMessage);
   }
 
+  public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+    Charset enc = RawParseUtils.parseEncoding(raw);
+
+    Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
+    return range.map(
+        commitMessageRange ->
+            RawParseUtils.decode(
+                enc,
+                raw,
+                commitMessageRange.changeMessageStart(),
+                commitMessageRange.changeMessageEnd() + 1));
+  }
+
   private void parseNotes() throws IOException, ConfigInvalidException {
     ObjectReader reader = walk.getObjectReader();
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 3eb06b2..c51aec3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -23,7 +23,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -39,6 +39,7 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -52,16 +53,15 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gson.Gson;
 import java.io.IOException;
@@ -430,7 +430,7 @@
     abstract ChangeNotesState build();
   }
 
-  static enum Serializer implements CacheSerializer<ChangeNotesState> {
+  enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
     @VisibleForTesting static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 894e979..66dd5e8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -88,7 +88,7 @@
     return comments;
   }
 
-  private static boolean isJson(byte[] raw, int offset) {
+  static boolean isJson(byte[] raw, int offset) {
     return raw[offset] == '{' || raw[offset] == '[';
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 445f7a0..b60a332 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -52,6 +52,7 @@
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -62,10 +63,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
@@ -160,6 +160,7 @@
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
+  private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
 
   @AssistedInject
   private ChangeUpdate(
@@ -395,6 +396,11 @@
         deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
   }
 
+  public void deleteChangeMessageByRewritingHistory(int targetMessageIdx, String newMessage) {
+    deleteChangeMessageRewriter =
+        new DeleteChangeMessageRewriter(getChange().getId(), targetMessageIdx, newMessage);
+  }
+
   @VisibleForTesting
   ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
@@ -402,6 +408,7 @@
       if (notes != null) {
         draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
       } else {
+        // tests will always take the notes != null path above.
         draftUpdate =
             draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
       }
@@ -526,14 +533,7 @@
     checkComments(rnm.revisionNotes, builders);
 
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      ObjectId data =
-          inserter.insert(
-              OBJ_BLOB,
-              e.getValue()
-                  .build(
-                      noteUtil.getChangeNoteJson(),
-                      noteUtil.getLegacyChangeNoteWrite(),
-                      noteUtil.getChangeNoteJson().getWriteJson()));
+      ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson()));
       rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
     }
 
@@ -615,7 +615,9 @@
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws OrmException, IOException {
-    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
+    checkState(
+        deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
+        "cannot update and rewrite ref in one BatchUpdate");
 
     CommitBuilder cb = new CommitBuilder();
 
@@ -829,6 +831,10 @@
     return deleteCommentRewriter;
   }
 
+  public DeleteChangeMessageRewriter getDeleteChangeMessageRewriter() {
+    return deleteChangeMessageRewriter;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
diff --git a/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java b/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java
new file mode 100644
index 0000000..b250a34
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java
@@ -0,0 +1,249 @@
+// Copyright (C) 2017 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.notedb;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.notedb.RevisionNote.MAX_NOTE_SZ;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.MutableInteger;
+
+@Singleton
+public class CommentJsonMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class ProjectMigrationResult {
+    public int skipped;
+    public boolean ok;
+    public List<String> refsUpdated;
+  }
+
+  private final LegacyChangeNoteRead legacyChangeNoteRead;
+  private final ChangeNoteJson changeNoteJson;
+  private final AllUsersName allUsers;
+
+  @Inject
+  CommentJsonMigrator(
+      ChangeNoteJson changeNoteJson,
+      GerritServerIdProvider gerritServerIdProvider,
+      AllUsersName allUsers) {
+    this.changeNoteJson = changeNoteJson;
+    this.allUsers = allUsers;
+    this.legacyChangeNoteRead = new LegacyChangeNoteRead(gerritServerIdProvider.get());
+  }
+
+  CommentJsonMigrator(ChangeNoteJson changeNoteJson, String serverId, AllUsersName allUsers) {
+    this.changeNoteJson = changeNoteJson;
+    this.legacyChangeNoteRead = new LegacyChangeNoteRead(serverId);
+    this.allUsers = allUsers;
+  }
+
+  public ProjectMigrationResult migrateProject(
+      Project.NameKey project, Repository repo, boolean dryRun) {
+    ProjectMigrationResult progress = new ProjectMigrationResult();
+    progress.ok = true;
+    progress.skipped = 0;
+    progress.refsUpdated = ImmutableList.of();
+    try (RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = newPackInserter(repo)) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      bru.setAllowNonFastForwards(true);
+      progress.ok &= migrateChanges(project, repo, rw, ins, bru);
+      if (project.equals(allUsers)) {
+        progress.ok &= migrateDrafts(allUsers, repo, rw, ins, bru);
+      }
+
+      progress.refsUpdated =
+          bru.getCommands().stream().map(c -> c.getRefName()).collect(toImmutableList());
+      if (!bru.getCommands().isEmpty()) {
+        if (!dryRun) {
+          ins.flush();
+          RefUpdateUtil.executeChecked(bru, rw);
+        }
+      } else {
+        progress.skipped++;
+      }
+    } catch (IOException e) {
+      progress.ok = false;
+    }
+
+    return progress;
+  }
+
+  private boolean migrateChanges(
+      Project.NameKey project, Repository repo, RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
+      throws IOException {
+    boolean ok = true;
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Change.Id changeId = Change.Id.fromRef(ref.getName());
+      if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+        continue;
+      }
+      ok &= migrateOne(project, rw, ins, bru, Status.PUBLISHED, changeId, ref);
+    }
+    return ok;
+  }
+
+  private boolean migrateDrafts(
+      Project.NameKey allUsers,
+      Repository allUsersRepo,
+      RevWalk rw,
+      ObjectInserter ins,
+      BatchRefUpdate bru)
+      throws IOException {
+    boolean ok = true;
+    for (Ref ref : allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+      Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+      if (changeId == null) {
+        continue;
+      }
+      ok &= migrateOne(allUsers, rw, ins, bru, Status.DRAFT, changeId, ref);
+    }
+    return ok;
+  }
+
+  private boolean migrateOne(
+      Project.NameKey project,
+      RevWalk rw,
+      ObjectInserter ins,
+      BatchRefUpdate bru,
+      Status status,
+      Change.Id changeId,
+      Ref ref) {
+    ObjectId oldId = ref.getObjectId();
+    try {
+      if (!hasAnyLegacyComments(rw, oldId)) {
+        return true;
+      }
+    } catch (IOException e) {
+      logger.atInfo().log(
+          String.format(
+              "Error reading change %s in %s; attempting migration anyway", changeId, project),
+          e);
+    }
+
+    try {
+      reset(rw, oldId);
+
+      ObjectReader reader = rw.getObjectReader();
+      ObjectId newId = null;
+      RevCommit c;
+      while ((c = rw.next()) != null) {
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(c.getAuthorIdent());
+        cb.setCommitter(c.getCommitterIdent());
+        cb.setMessage(c.getFullMessage());
+        cb.setEncoding(c.getEncoding());
+        if (newId != null) {
+          cb.setParentId(newId);
+        }
+
+        // Read/write using the low-level RevisionNote API, which works regardless of NotesMigration
+        // state.
+        NoteMap noteMap = NoteMap.read(reader, c);
+        RevisionNoteMap<ChangeRevisionNote> revNoteMap =
+            RevisionNoteMap.parse(
+                changeNoteJson, legacyChangeNoteRead, changeId, reader, noteMap, status);
+        RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNoteMap);
+
+        for (RevId revId : revNoteMap.revisionNotes.keySet()) {
+          // Call cache.get on each known RevId to read the old note in whichever format, then write
+          // the note in JSON format.
+          byte[] data = cache.get(revId).build(changeNoteJson);
+          noteMap.set(ObjectId.fromString(revId.get()), ins.insert(OBJ_BLOB, data));
+        }
+        cb.setTreeId(noteMap.writeTree(ins));
+        newId = ins.insert(cb);
+      }
+
+      bru.addCommand(new ReceiveCommand(oldId, newId, ref.getName()));
+      return true;
+    } catch (ConfigInvalidException | IOException e) {
+      logger.atInfo().log(String.format("Error migrating change %s in %s", changeId, project), e);
+      return false;
+    }
+  }
+
+  private static boolean hasAnyLegacyComments(RevWalk rw, ObjectId id) throws IOException {
+    ObjectReader reader = rw.getObjectReader();
+    reset(rw, id);
+
+    // Check the note map at each commit, not just the tip. It's possible that the server switched
+    // from legacy to JSON partway through its history, which would have mixed legacy/JSON comments
+    // in its history. Although the tip commit would continue to parse once we remove the legacy
+    // parser, our goal is really to expunge all vestiges of the old format, which implies rewriting
+    // history (and thus returning true) in this case.
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      NoteMap noteMap = NoteMap.read(reader, c);
+      for (Note note : noteMap) {
+        // Match pre-parsing logic in RevisionNote#parse().
+        byte[] raw = reader.open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        MutableInteger p = new MutableInteger();
+        RevisionNote.trimLeadingEmptyLines(raw, p);
+        if (!ChangeRevisionNote.isJson(raw, p.value)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static void reset(RevWalk rw, ObjectId id) throws IOException {
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE);
+    rw.markStart(rw.parseCommit(id));
+  }
+
+  private static ObjectInserter newPackInserter(Repository repo) {
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
new file mode 100644
index 0000000..212aa37
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2018 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.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static org.eclipse.jgit.util.RawParseUtils.decode;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.Optional;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Deletes a change message from NoteDb by rewriting the commit history. After deletion, the whole
+ * change message will be replaced by a new message indicating the original change message has been
+ * deleted for the given reason.
+ */
+public class DeleteChangeMessageRewriter implements NoteDbRewriter {
+
+  private final Change.Id changeId;
+  private final int targetMessageIdx;
+  private final String newChangeMessage;
+
+  DeleteChangeMessageRewriter(Change.Id changeId, int targetMessageIdx, String newChangeMessage) {
+    this.changeId = changeId;
+    this.targetMessageIdx = targetMessageIdx;
+    this.newChangeMessage = newChangeMessage;
+  }
+
+  @Override
+  public String getRefName() {
+    return RefNames.changeMetaRef(changeId);
+  }
+
+  @Override
+  public ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException {
+    checkArgument(!currTip.equals(ObjectId.zeroId()));
+
+    // Walk from the first commit of the branch.
+    revWalk.reset();
+    revWalk.markStart(revWalk.parseCommit(currTip));
+    revWalk.sort(RevSort.TOPO);
+    revWalk.sort(RevSort.REVERSE);
+
+    ObjectId newTipId = null;
+    RevCommit originalCommit;
+    int idx = 0;
+    while ((originalCommit = revWalk.next()) != null) {
+      if (idx < targetMessageIdx) {
+        newTipId = originalCommit;
+        idx++;
+        continue;
+      }
+
+      String newCommitMessage =
+          (idx == targetMessageIdx)
+              ? createNewCommitMessage(originalCommit)
+              : originalCommit.getFullMessage();
+      newTipId = rewriteOneCommit(originalCommit, newTipId, newCommitMessage, inserter);
+
+      idx++;
+    }
+    return newTipId;
+  }
+
+  private String createNewCommitMessage(RevCommit commit) {
+    byte[] raw = commit.getRawBuffer();
+
+    Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
+    checkState(range.isPresent(), "failed to parse commit message");
+
+    // Only replace the commit message body, which is the user-provided message. The subject and
+    // footers are NoteDb metadata.
+    Charset encoding = RawParseUtils.parseEncoding(raw);
+    String prefix =
+        decode(encoding, raw, range.get().subjectStart(), range.get().changeMessageStart());
+    String postfix = decode(encoding, raw, range.get().changeMessageEnd() + 1, raw.length);
+    return prefix + newChangeMessage + postfix;
+  }
+
+  /**
+   * Rewrites one commit.
+   *
+   * @param originalCommit the original commit to be rewritten.
+   * @param parentCommitId the parent of the new commit. For the first rewritten commit, it's the
+   *     parent of 'originalCommit'. For the latter rewritten commits, it's the commit rewritten
+   *     just before it.
+   * @param commitMessage the full commit message of the new commit.
+   * @param inserter the {@code ObjectInserter} for the rewrite process.
+   * @return the {@code objectId} of the new commit.
+   * @throws IOException
+   */
+  private ObjectId rewriteOneCommit(
+      RevCommit originalCommit,
+      ObjectId parentCommitId,
+      String commitMessage,
+      ObjectInserter inserter)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    if (parentCommitId != null) {
+      cb.setParentId(parentCommitId);
+    }
+    cb.setTreeId(originalCommit.getTree());
+    cb.setMessage(commitMessage);
+    cb.setCommitter(originalCommit.getCommitterIdent());
+    cb.setAuthor(originalCommit.getAuthorIdent());
+    cb.setEncoding(originalCommit.getEncoding());
+    return inserter.insert(cb);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9a8c130..0cd3452 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -239,13 +239,7 @@
     Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
     for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
       ObjectId objectId = ObjectId.fromString(entry.getKey().get());
-      byte[] data =
-          entry
-              .getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+      byte[] data = entry.getValue().build(noteUtil.getChangeNoteJson());
       if (data.length == 0) {
         revNotesMap.noteMap.remove(objectId);
       } else {
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 79da7e1..81d32d9 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -150,6 +150,8 @@
       return;
     }
 
+    logger.atFine().log(
+        "Load draft comment notes for change %s of project %s", getChangeId(), getProjectName());
     RevCommit tipCommit = handle.walk().parseCommit(rev);
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
diff --git a/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
index 34ed64c..347ba48 100644
--- a/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
+++ b/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -30,11 +31,17 @@
   GwtormChangeBundleReader() {}
 
   @Override
+  @Nullable
   public ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException {
+    Change reviewDbChange = db.changes().get(id);
+    if (reviewDbChange == null) {
+      return null;
+    }
+
     // TODO(dborowitz): Figure out how to do this more consistently, e.g. hand-written inner joins.
     List<PatchSetApproval> approvals = db.patchSetApprovals().byChange(id).toList();
     return new ChangeBundle(
-        db.changes().get(id),
+        reviewDbChange,
         db.changeMessages().byChange(id),
         db.patchSets().byChange(id),
         approvals,
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
index 1cf0c7c..c9711b5 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
@@ -15,59 +15,49 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Date;
 import java.util.List;
-import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.QuotedString;
 
 public class LegacyChangeNoteWrite {
 
-  private final AccountCache accountCache;
   private final PersonIdent serverIdent;
   private final String serverId;
 
   @Inject
   public LegacyChangeNoteWrite(
-      AccountCache accountCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @GerritServerId String serverId) {
-    this.accountCache = accountCache;
+      @GerritPersonIdent PersonIdent serverIdent, @GerritServerId String serverId) {
     this.serverIdent = serverIdent;
     this.serverId = serverId;
   }
 
   public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
-    Optional<Account> author = accountCache.get(authorId).map(AccountState::getAccount);
     return new PersonIdent(
-        author.map(Account::getName).orElseGet(() -> Account.getName(authorId)),
-        authorId.get() + "@" + serverId,
-        when,
-        serverIdent.getTimeZone());
+        authorId.toString(), authorId.get() + "@" + serverId, when, serverIdent.getTimeZone());
   }
 
   @VisibleForTesting
   public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
     return new PersonIdent(
-        author.getName(), author.getId().get() + "@" + serverId, when, serverIdent.getTimeZone());
+        author.toString(), author.getId().get() + "@" + serverId, when, serverIdent.getTimeZone());
   }
 
   public String getServerId() {
@@ -91,13 +81,13 @@
    *     the same side.
    * @param out output stream to write to.
    */
-  void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
     if (comments.isEmpty()) {
       return;
     }
 
-    List<Integer> psIds = new ArrayList<>(comments.keySet());
-    Collections.sort(psIds);
+    ImmutableList<Integer> psIds = comments.keySet().stream().sorted().collect(toImmutableList());
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index c599c8e..fd25059 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -467,14 +467,33 @@
     if (rcu != null) {
       robotCommentUpdates.put(rcu.getRefName(), rcu);
     }
-    DeleteCommentRewriter rwt = update.getDeleteCommentRewriter();
-    if (rwt != null) {
-      // Checks whether there is any ChangeUpdate added earlier trying to update the same ref.
+    DeleteCommentRewriter deleteCommentRewriter = update.getDeleteCommentRewriter();
+    if (deleteCommentRewriter != null) {
+      // Checks whether there is any ChangeUpdate or rewriter added earlier for the same ref.
       checkArgument(
-          !changeUpdates.containsKey(rwt.getRefName()),
+          !changeUpdates.containsKey(deleteCommentRewriter.getRefName()),
           "cannot update & rewrite ref %s in one BatchUpdate",
-          rwt.getRefName());
-      rewriters.put(rwt.getRefName(), rwt);
+          deleteCommentRewriter.getRefName());
+      checkArgument(
+          !rewriters.containsKey(deleteCommentRewriter.getRefName()),
+          "cannot rewrite the same ref %s in one BatchUpdate",
+          deleteCommentRewriter.getRefName());
+      rewriters.put(deleteCommentRewriter.getRefName(), deleteCommentRewriter);
+    }
+
+    DeleteChangeMessageRewriter deleteChangeMessageRewriter =
+        update.getDeleteChangeMessageRewriter();
+    if (deleteChangeMessageRewriter != null) {
+      // Checks whether there is any ChangeUpdate or rewriter added earlier for the same ref.
+      checkArgument(
+          !changeUpdates.containsKey(deleteChangeMessageRewriter.getRefName()),
+          "cannot update & rewrite ref %s in one BatchUpdate",
+          deleteChangeMessageRewriter.getRefName());
+      checkArgument(
+          !rewriters.containsKey(deleteChangeMessageRewriter.getRefName()),
+          "cannot rewrite the same ref %s in one BatchUpdate",
+          deleteChangeMessageRewriter.getRefName());
+      rewriters.put(deleteChangeMessageRewriter.getRefName(), deleteChangeMessageRewriter);
     }
 
     changeUpdates.put(update.getRefName(), update);
@@ -724,7 +743,7 @@
 
     // Just scan repo for ref names, but get "old" values from cmds.
     for (Ref r :
-        allUsersRepo.repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
+        allUsersRepo.repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
       old = allUsersRepo.cmds.get(r.getName());
       if (old.isPresent()) {
         allUsersRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
diff --git a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
index 43ed722..9dd3933 100644
--- a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -433,7 +433,7 @@
     ImmutableMap.Builder<Account.Id, ObjectId> draftIds = ImmutableMap.builder();
     try (Repository repo = repoManager.openRepository(allUsers)) {
       for (Ref draftRef :
-          repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
+          repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
         Account.Id accountId = Account.Id.fromRef(draftRef.getName());
         if (accountId != null) {
           draftIds.put(accountId, draftRef.getObjectId().copy());
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 8bf286d..b8c7d7d 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -82,19 +82,13 @@
     delete = new HashSet<>();
   }
 
-  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson) throws IOException {
-    return build(noteUtil.getChangeNoteJson(), noteUtil.getLegacyChangeNoteWrite(), writeJson);
+  public byte[] build(ChangeNoteUtil noteUtil) throws IOException {
+    return build(noteUtil.getChangeNoteJson());
   }
 
-  public byte[] build(
-      ChangeNoteJson changeNoteJson, LegacyChangeNoteWrite legacyChangeNoteWrite, boolean writeJson)
-      throws IOException {
+  public byte[] build(ChangeNoteJson changeNoteJson) throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    if (writeJson) {
-      buildNoteJson(changeNoteJson, out);
-    } else {
-      buildNoteLegacy(legacyChangeNoteWrite, out);
-    }
+    buildNoteJson(changeNoteJson, out);
     return out.toByteArray();
   }
 
@@ -142,22 +136,4 @@
       noteUtil.getGson().toJson(data, osw);
     }
   }
-
-  private void buildNoteLegacy(LegacyChangeNoteWrite noteUtil, OutputStream out)
-      throws IOException {
-    if (pushCert != null) {
-      byte[] certBytes = pushCert.getBytes(UTF_8);
-      out.write(certBytes, 0, trimTrailingNewlines(certBytes));
-      out.write('\n');
-    }
-    noteUtil.buildNote(buildCommentMap(), out);
-  }
-
-  private static int trimTrailingNewlines(byte[] bytes) {
-    int p = bytes.length;
-    while (p > 1 && bytes[p - 1] == '\n') {
-      p--;
-    }
-    return p;
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index 7eb3a54..a05e6a1 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -34,6 +35,8 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class RobotCommentNotes extends AbstractChangeNotes<RobotCommentNotes> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     RobotCommentNotes create(Change change);
   }
@@ -86,6 +89,8 @@
     }
     metaId = metaId.copy();
 
+    logger.atFine().log(
+        "Load robot comment notes for change %s of project %s", getChangeId(), getProjectName());
     RevCommit tipCommit = handle.walk().parseCommit(metaId);
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 3a0d595..e7ac5b7 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -142,7 +142,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil, true);
+      byte[] data = e.getValue().build(noteUtil);
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index 3a0bfc1..ab2203e 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -546,7 +546,7 @@
     if (id == null) {
       return new PersonIdent(serverIdent, events.getWhen());
     }
-    return changeNoteUtil.getLegacyChangeNoteWrite().newIdent(id, events.getWhen(), serverIdent);
+    return changeNoteUtil.newIdent(id, events.getWhen(), serverIdent);
   }
 
   private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
@@ -620,8 +620,7 @@
         allUsersRepo
             .repo
             .getRefDatabase()
-            .getRefs(RefNames.refsDraftCommentsPrefix(change.getId()))
-            .values()) {
+            .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(change.getId()))) {
       allUsersRepo.cmds.add(new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index e064a8c..6a801ec 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -362,7 +362,8 @@
           primaryStorageMigrator,
           listeners,
           threads > 1
-              ? MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "RebuildChange"))
+              ? MoreExecutors.listeningDecorator(
+                  workQueue.createQueue(threads, "RebuildChange", true))
               : MoreExecutors.newDirectExecutorService(),
           projects,
           changes,
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f1b6639..15fedd7 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.inject.Inject;
@@ -54,6 +55,7 @@
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index 5359479..eb6a280 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -31,7 +32,8 @@
   @Singleton
   @DiffExecutor
   public ExecutorService createDiffExecutor() {
-    return Executors.newCachedThreadPool(
-        new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build());
+    return new LoggingContextAwareExecutorService(
+        Executors.newCachedThreadPool(
+            new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index 8bca19f..9153638 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -19,7 +19,6 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Callable;
 
@@ -66,8 +65,9 @@
           break;
       }
     }
-    Collections.sort(r);
     return new DiffSummary(
-        r.toArray(new String[r.size()]), patchList.getInsertions(), patchList.getDeletions());
+        r.stream().sorted().toArray(String[]::new),
+        patchList.getInsertions(),
+        patchList.getDeletions());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index 06e2c45..ccf4e6b 100644
--- a/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -21,7 +21,7 @@
 
 @AutoValue
 public abstract class IntraLineDiffKey implements Serializable {
-  public static final long serialVersionUID = 12L;
+  public static final long serialVersionUID = 13L;
 
   public static IntraLineDiffKey create(ObjectId aId, ObjectId bId, Whitespace whitespace) {
     return new AutoValue_IntraLineDiffKey(aId, bId, whitespace);
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index cf5df4a..dd717ba 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -47,12 +47,7 @@
   private static final long serialVersionUID = PatchListKey.serialVersionUID;
 
   private static final Comparator<PatchListEntry> PATCH_CMP =
-      new Comparator<PatchListEntry>() {
-        @Override
-        public int compare(PatchListEntry a, PatchListEntry b) {
-          return comparePaths(a.getNewName(), b.getNewName());
-        }
-      };
+      Comparator.comparing(PatchListEntry::getNewName, PatchList::comparePaths);
 
   @VisibleForTesting
   static int comparePaths(String a, String b) {
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index 96f66f6..6b1a153 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readBytes;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readEnum;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readFixInt64;
@@ -24,6 +26,7 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeFixInt64;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -39,7 +42,6 @@
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.patch.CombinedFileHeader;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.IntList;
@@ -187,11 +189,13 @@
   }
 
   public ImmutableList<Edit> getEdits() {
-    return edits;
+    // Edits are mutable objects. As we serialize PatchListEntry asynchronously in H2CacheImpl, we
+    // must ensure that its state isn't modified until it was properly stored in the cache.
+    return deepCopyEdits(edits);
   }
 
   public ImmutableSet<Edit> getEditsDueToRebase() {
-    return editsDueToRebase;
+    return deepCopyEdits(editsDueToRebase);
   }
 
   public int getInsertions() {
@@ -219,7 +223,7 @@
       if (header[e - 1] == '\n') {
         e--;
       }
-      headerLines.add(RawParseUtils.decode(Constants.CHARSET, header, b, e));
+      headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
     }
     return headerLines;
   }
@@ -234,6 +238,18 @@
     return p;
   }
 
+  private static ImmutableList<Edit> deepCopyEdits(List<Edit> edits) {
+    return edits.stream().map(PatchListEntry::copy).collect(toImmutableList());
+  }
+
+  private static ImmutableSet<Edit> deepCopyEdits(Set<Edit> edits) {
+    return edits.stream().map(PatchListEntry::copy).collect(toImmutableSet());
+  }
+
+  private static Edit copy(Edit edit) {
+    return new Edit(edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
+  }
+
   void writeTo(OutputStream out) throws IOException {
     writeEnum(out, changeType);
     writeEnum(out, patchType);
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index 083c142..2df6d66 100644
--- a/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -32,7 +32,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 public class PatchListKey implements Serializable {
-  public static final long serialVersionUID = 31L;
+  public static final long serialVersionUID = 32L;
 
   public static final ImmutableBiMap<Whitespace, Character> WHITESPACE_TYPES =
       ImmutableBiMap.of(
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 6f3e055..61f0180 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.CommentDetail;
@@ -34,9 +35,9 @@
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -54,13 +55,7 @@
   static final int MAX_CONTEXT = 5000000;
   static final int BIG_FILE = 9000;
 
-  private static final Comparator<Edit> EDIT_SORT =
-      new Comparator<Edit>() {
-        @Override
-        public int compare(Edit o1, Edit o2) {
-          return o1.getBeginA() - o2.getBeginA();
-        }
-      };
+  private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
 
   private Repository db;
   private Project.NameKey projectKey;
@@ -172,6 +167,8 @@
       }
     }
 
+    correctForDifferencesInNewlineAtEnd();
+
     if (comments != null) {
       ensureCommentsVisible(comments);
     }
@@ -277,6 +274,50 @@
     }
   }
 
+  private void correctForDifferencesInNewlineAtEnd() {
+    // a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
+    int aSize = a.src.size();
+    int bSize = b.src.size();
+
+    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
+      // The diff was requested for a file which was either added or deleted but which JGit doesn't
+      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
+      // renamed file looks like a deletion).
+      return;
+    }
+
+    Optional<Edit> lastEdit = getLast(edits);
+    if (isNewlineAtEndDeleted()) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
+      if (lastLineEdit.isPresent()) {
+        lastLineEdit.get().extendA();
+      } else {
+        Edit newlineEdit = new Edit(aSize, aSize + 1, bSize, bSize);
+        edits.add(newlineEdit);
+      }
+    } else if (isNewlineAtEndAdded()) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndB() == bSize);
+      if (lastLineEdit.isPresent()) {
+        lastLineEdit.get().extendB();
+      } else {
+        Edit newlineEdit = new Edit(aSize, aSize, bSize, bSize + 1);
+        edits.add(newlineEdit);
+      }
+    }
+  }
+
+  private static <T> Optional<T> getLast(List<T> list) {
+    return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(list.size() - 1));
+  }
+
+  private boolean isNewlineAtEndDeleted() {
+    return !a.src.isMissingNewlineAtEnd() && b.src.isMissingNewlineAtEnd();
+  }
+
+  private boolean isNewlineAtEndAdded() {
+    return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
+  }
+
   private void ensureCommentsVisible(CommentDetail comments) {
     if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
       // No comments, no additional dummy edits are required.
@@ -322,7 +363,7 @@
     // them correctly later.
     //
     edits.addAll(empty);
-    Collections.sort(edits, EDIT_SORT);
+    edits.sort(EDIT_SORT);
   }
 
   private void safeAdd(List<Edit> empty, Edit toAdd) {
@@ -396,14 +437,14 @@
     for (EditList.Hunk hunk : list.getHunks()) {
       while (hunk.next()) {
         if (hunk.isContextLine()) {
-          final String lineA = a.src.getString(hunk.getCurA());
+          String lineA = a.getSourceLine(hunk.getCurA());
           a.dst.addLine(hunk.getCurA(), lineA);
 
           if (ignoredWhitespace) {
             // If we ignored whitespace in some form, also get the line
             // from b when it does not exactly match the line from a.
             //
-            final String lineB = b.src.getString(hunk.getCurB());
+            String lineB = b.getSourceLine(hunk.getCurB());
             if (!lineA.equals(lineB)) {
               b.dst.addLine(hunk.getCurB(), lineB);
             }
@@ -437,11 +478,22 @@
     final SparseFileContent dst = new SparseFileContent();
 
     int size() {
-      return src != null ? src.size() : 0;
+      if (src == null) {
+        return 0;
+      }
+      if (src.isMissingNewlineAtEnd()) {
+        return src.size();
+      }
+      return src.size() + 1;
     }
 
-    void addLine(int line) {
-      dst.addLine(line, src.getString(line));
+    void addLine(int lineNumber) {
+      String lineContent = getSourceLine(lineNumber);
+      dst.addLine(lineNumber, lineContent);
+    }
+
+    String getSourceLine(int lineNumber) {
+      return lineNumber >= src.size() ? "" : src.getString(lineNumber);
     }
 
     void resolve(Side other, ObjectId within) throws IOException {
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 3a17965..f4e659e 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 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.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -48,16 +48,11 @@
   static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
-    private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
     @Inject
-    Factory(
-        ChangeData.Factory changeDataFactory,
-        ChangeNotes.Factory notesFactory,
-        IdentifiedUser.GenericFactory identifiedUserFactory) {
+    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
       this.changeDataFactory = changeDataFactory;
       this.notesFactory = notesFactory;
-      this.identifiedUserFactory = identifiedUserFactory;
     }
 
     ChangeControl create(
@@ -67,22 +62,17 @@
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, identifiedUserFactory, refControl, notes);
+      return new ChangeControl(changeDataFactory, refControl, notes);
     }
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final RefControl refControl;
   private final ChangeNotes notes;
 
   private ChangeControl(
-      ChangeData.Factory changeDataFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      RefControl refControl,
-      ChangeNotes notes) {
+      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
     this.changeDataFactory = changeDataFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.refControl = refControl;
     this.notes = notes;
   }
@@ -91,14 +81,6 @@
     return new ForChangeImpl(cd, db);
   }
 
-  private ChangeControl forUser(CurrentUser who) {
-    if (getUser().equals(who)) {
-      return this;
-    }
-    return new ChangeControl(
-        changeDataFactory, identifiedUserFactory, refControl.forUser(who), notes);
-  }
-
   private CurrentUser getUser() {
     return refControl.getUser();
   }
@@ -263,21 +245,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForChange user(CurrentUser user) {
-      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
-    }
-
-    @Override
-    public ForChange absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
@@ -308,6 +275,11 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      return new PermissionBackendCondition.ForChange(this, perm, getUser());
+    }
+
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
@@ -327,8 +299,7 @@
           case ABANDON:
             return canAbandon();
           case DELETE:
-            return (isOwner() && refControl.canPerform(Permission.DELETE_OWN_CHANGES))
-                || getProjectControl().isAdmin();
+            return (getProjectControl().isAdmin() || (refControl.canDeleteChanges(isOwner())));
           case ADD_PATCH_SET:
             return canAddPatchSet();
           case EDIT_ASSIGNEE:
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 3f25562f..8cf9444 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -19,10 +19,12 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -45,6 +47,8 @@
 
 @Singleton
 public class DefaultPermissionBackend extends PermissionBackend {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
 
   private final Provider<CurrentUser> currentUser;
@@ -98,11 +102,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      return user;
-    }
-
-    @Override
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.checkedGet(project);
@@ -138,6 +137,11 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      return new PermissionBackendCondition.WithUser(this, perm, user);
+    }
+
     private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
       if (perm instanceof GlobalPermission) {
         return can((GlobalPermission) perm);
@@ -186,6 +190,13 @@
     private boolean isAdmin() {
       if (admin == null) {
         admin = computeAdmin();
+        if (admin) {
+          logger.atFinest().log(
+              "user %s is an administrator of the server", user.getLoggableName());
+        } else {
+          logger.atFinest().log(
+              "user %s is not an administrator of the server", user.getLoggableName());
+        }
       }
       return admin;
     }
@@ -210,11 +221,32 @@
 
     private boolean canEmailReviewers() {
       List<PermissionRule> email = capabilities().emailReviewers;
-      return allow(email) || notDenied(email);
+      if (allow(email)) {
+        logger.atFinest().log(
+            "user %s can email reviewers (allowed by %s)", user.getLoggableName(), email);
+        return true;
+      }
+
+      if (notDenied(email)) {
+        logger.atFinest().log(
+            "user %s can email reviewers (not denied by %s)", user.getLoggableName(), email);
+        return true;
+      }
+
+      logger.atFinest().log("user %s cannot email reviewers", user.getLoggableName());
+      return false;
     }
 
     private boolean has(String permissionName) {
-      return allow(capabilities().getPermission(checkNotNull(permissionName)));
+      boolean has = allow(capabilities().getPermission(checkNotNull(permissionName)));
+      if (has) {
+        logger.atFinest().log(
+            "user %s has global capability %s", user.getLoggableName(), permissionName);
+      } else {
+        logger.atFinest().log(
+            "user %s doesn't have global capability %s", user.getLoggableName(), permissionName);
+      }
+      return has;
     }
 
     private boolean allow(Collection<PermissionRule> rules) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 3b88080..2bc40c1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
-import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -55,7 +55,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -105,16 +104,15 @@
         permissionBackend.user(user).database(db).project(projectState.getNameKey());
   }
 
-  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts) {
+  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+      throws PermissionBackendException {
     if (projectState.isAllUsers()) {
       refs = addUsersSelfSymref(refs);
     }
 
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
     if (!projectState.isAllUsers()) {
       if (projectState.statePermitsRead()
-          && checkProjectPermission(forProject, ProjectPermission.READ)) {
+          && checkProjectPermission(permissionBackendForProject, ProjectPermission.READ)) {
         return refs;
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         return fastHideRefsMetaConfig(refs);
@@ -125,6 +123,7 @@
     boolean isAdmin;
     Account.Id userId;
     IdentifiedUser identifiedUser;
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
     if (user.isIdentifiedUser()) {
       viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
       isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
@@ -229,7 +228,8 @@
     return result;
   }
 
-  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
+  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs)
+      throws PermissionBackendException {
     if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
       Map<String, Ref> r = new HashMap<>(refs);
       r.remove(REFS_CONFIG);
@@ -250,7 +250,7 @@
     return refs;
   }
 
-  private boolean visible(Repository repo, Change.Id changeId) {
+  private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
     if (visibleChanges == null) {
       if (changeCache == null) {
         visibleChanges = visibleChangesByScan(repo);
@@ -261,7 +261,7 @@
     return visibleChanges.containsKey(changeId);
   }
 
-  private boolean visibleEdit(Repository repo, String name) {
+  private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     // Initialize if it wasn't yet
     if (visibleChanges == null) {
@@ -284,35 +284,38 @@
         return true;
       } catch (AuthException e) {
         return false;
-      } catch (PermissionBackendException e) {
-        logger.atSevere().withCause(e).log(
-            "Failed to check permission for %s in %s", id, projectState.getName());
-        return false;
       }
     }
     return false;
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch()
+      throws PermissionBackendException {
     Project.NameKey project = projectState.getNameKey();
     try {
       Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
         ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
-        if (projectState.statePermitsRead()
-            && permissionBackendForProject.indexedChange(cd, notes).test(ChangePermission.READ)) {
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+        try {
+          permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
           visibleChanges.put(cd.getId(), cd.change().getDest());
+        } catch (AuthException e) {
+          // Do nothing.
         }
       }
       return visibleChanges;
-    } catch (OrmException | PermissionBackendException e) {
+    } catch (OrmException e) {
       logger.atSevere().withCause(e).log(
           "Cannot load changes for project %s, assuming no changes are visible", project);
       return Collections.emptyMap();
     }
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo) {
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo)
+      throws PermissionBackendException {
     Project.NameKey p = projectState.getNameKey();
     Stream<ChangeNotesResult> s;
     try {
@@ -322,26 +325,34 @@
           "Cannot load changes for project %s, assuming no changes are visible", p);
       return Collections.emptyMap();
     }
-    return s.map(this::toNotes)
-        .filter(Objects::nonNull)
-        .collect(toMap(AbstractChangeNotes::getChangeId, n -> n.getChange().getDest()));
+
+    Map<Change.Id, Branch.NameKey> result = Maps.newHashMapWithExpectedSize((int) s.count());
+    for (ChangeNotesResult notesResult : s.collect(toImmutableList())) {
+      ChangeNotes notes = toNotes(notesResult);
+      if (notes != null) {
+        result.put(notes.getChangeId(), notes.getChange().getDest());
+      }
+    }
+    return result;
   }
 
   @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) {
+  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
     if (r.error().isPresent()) {
       logger.atWarning().withCause(r.error().get()).log(
           "Failed to load change %s in %s", r.id(), projectState.getName());
       return null;
     }
+
+    if (!projectState.statePermitsRead()) {
+      return null;
+    }
+
     try {
-      if (projectState.statePermitsRead()
-          && permissionBackendForProject.change(r.notes()).test(ChangePermission.READ)) {
-        return r.notes();
-      }
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check permission for %s in %s", r.id(), projectState.getName());
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
+    } catch (AuthException e) {
+      // Skip.
     }
     return null;
   }
@@ -358,28 +369,22 @@
     return ref.getName().startsWith(REFS_USERS_SELF);
   }
 
-  private boolean canReadRef(String ref) {
+  private boolean canReadRef(String ref) throws PermissionBackendException {
     try {
       permissionBackendForProject.ref(ref).check(RefPermission.READ);
     } catch (AuthException e) {
       return false;
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("unable to check permissions");
-      return false;
     }
     return projectState.statePermitsRead();
   }
 
   private boolean checkProjectPermission(
-      PermissionBackend.ForProject forProject, ProjectPermission perm) {
+      PermissionBackend.ForProject forProject, ProjectPermission perm)
+      throws PermissionBackendException {
     try {
       forProject.check(perm);
     } catch (AuthException e) {
       return false;
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log(
-          "Can't check permission for user %s on project %s", user, projectState.getName());
-      return false;
     }
     return true;
   }
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 431bfd9..bd7c549 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -15,10 +15,9 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 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.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
@@ -84,11 +83,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
     public ForProject project(Project.NameKey project) {
       return new FailedProject(message, cause);
     }
@@ -103,6 +97,12 @@
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
+
+    @Override
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
   }
 
   private static class FailedProject extends ForProject {
@@ -120,21 +120,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -157,6 +142,12 @@
     }
 
     @Override
+    public BooleanCondition testCond(ProjectPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
+
+    @Override
     public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
@@ -178,21 +169,6 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
-    }
-
-    @Override
-    public ForRef user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForRef absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -223,6 +199,12 @@
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
+
+    @Override
+    public BooleanCondition testCond(RefPermission perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
+    }
   }
 
   private static class FailedChange extends ForChange {
@@ -240,16 +222,6 @@
     }
 
     @Override
-    public ForChange user(CurrentUser user) {
-      return this;
-    }
-
-    @Override
-    public ForChange absentUser(Account.Id id) {
-      return this;
-    }
-
-    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -267,8 +239,9 @@
     }
 
     @Override
-    public CurrentUser user() {
-      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      throw new UnsupportedOperationException(
+          "FailedPermissionBackend does not support conditions");
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 211180f..07c9e84 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.access.GerritPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.registration.PluginName;
 import java.lang.annotation.Annotation;
 import java.util.Collections;
 import java.util.LinkedHashSet;
@@ -117,7 +118,7 @@
       Class<?> annotationClass)
       throws PermissionBackendException {
     if (pluginName != null
-        && !"gerrit".equals(pluginName)
+        && !PluginName.GERRIT.equals(pluginName)
         && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
       return new PluginPermission(pluginName, capability, fallBackToAdmin);
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 357770d..4719aa9 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -173,9 +173,6 @@
 
   /** PermissionBackend scoped to a specific user. */
   public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
     /** Returns an instance scoped for the specified project. */
     public abstract ForProject project(Project.NameKey project);
 
@@ -257,9 +254,7 @@
       }
     }
 
-    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
-      return new PermissionBackendCondition.WithUser(this, perm);
-    }
+    public abstract BooleanCondition testCond(GlobalOrPluginPermission perm);
 
     /**
      * Filter a set of projects using {@code check(perm)}.
@@ -296,18 +291,9 @@
 
   /** PermissionBackend scoped to a user and project. */
   public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same project, but different {@code user}. */
-    public abstract ForProject user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForProject absentUser(Account.Id id);
-
     /** Returns an instance scoped for {@code ref} in this project. */
     public abstract ForRef ref(String ref);
 
@@ -355,9 +341,7 @@
       }
     }
 
-    public BooleanCondition testCond(ProjectPermission perm) {
-      return new PermissionBackendCondition.ForProject(this, perm);
-    }
+    public abstract BooleanCondition testCond(ProjectPermission perm);
 
     /**
      * Filter a map of references by visibility.
@@ -407,18 +391,9 @@
 
   /** PermissionBackend scoped to a user, project and reference. */
   public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
     /** Returns a fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same reference, but different {@code user}. */
-    public abstract ForRef user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForRef absentUser(Account.Id id);
-
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeData cd);
 
@@ -461,25 +436,14 @@
       }
     }
 
-    public BooleanCondition testCond(RefPermission perm) {
-      return new PermissionBackendCondition.ForRef(this, perm);
-    }
+    public abstract BooleanCondition testCond(RefPermission perm);
   }
 
   /** PermissionBackend scoped to a user, project, reference and change. */
   public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
-    /** Returns the user this instance is scoped to. */
-    public abstract CurrentUser user();
-
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Returns a new instance rescoped to same change, but different {@code user}. */
-    public abstract ForChange user(CurrentUser user);
-
-    /** @see PermissionBackend#absentUser(Account.Id) */
-    public abstract ForChange absentUser(Account.Id id);
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
@@ -511,9 +475,7 @@
       }
     }
 
-    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
-      return new PermissionBackendCondition.ForChange(this, perm);
-    }
+    public abstract BooleanCondition testCond(ChangePermissionOrLabel perm);
 
     /**
      * Test which values of a label the user may be able to set.
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
index 3a661cda..1b6b087 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
@@ -56,10 +56,13 @@
   public static class WithUser extends PermissionBackendCondition {
     private final PermissionBackend.WithUser impl;
     private final GlobalOrPluginPermission perm;
+    private final CurrentUser user;
 
-    WithUser(PermissionBackend.WithUser impl, GlobalOrPluginPermission perm) {
+    public WithUser(
+        PermissionBackend.WithUser impl, GlobalOrPluginPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.WithUser withUser() {
@@ -82,7 +85,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, hashForUser(impl.user()));
+      return Objects.hash(perm, hashForUser(user));
     }
 
     @Override
@@ -91,17 +94,19 @@
         return false;
       }
       WithUser other = (WithUser) obj;
-      return Objects.equals(perm, other.perm) && usersAreEqual(impl.user(), other.impl.user());
+      return Objects.equals(perm, other.perm) && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForProject extends PermissionBackendCondition {
     private final PermissionBackend.ForProject impl;
     private final ProjectPermission perm;
+    private final CurrentUser user;
 
-    ForProject(PermissionBackend.ForProject impl, ProjectPermission perm) {
+    public ForProject(PermissionBackend.ForProject impl, ProjectPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForProject project() {
@@ -124,7 +129,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -135,17 +140,19 @@
       ForProject other = (ForProject) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForRef extends PermissionBackendCondition {
     private final PermissionBackend.ForRef impl;
     private final RefPermission perm;
+    private final CurrentUser user;
 
-    ForRef(PermissionBackend.ForRef impl, RefPermission perm) {
+    public ForRef(PermissionBackend.ForRef impl, RefPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForRef ref() {
@@ -168,7 +175,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -179,17 +186,20 @@
       ForRef other = (ForRef) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
   public static class ForChange extends PermissionBackendCondition {
     private final PermissionBackend.ForChange impl;
     private final ChangePermissionOrLabel perm;
+    private final CurrentUser user;
 
-    ForChange(PermissionBackend.ForChange impl, ChangePermissionOrLabel perm) {
+    public ForChange(
+        PermissionBackend.ForChange impl, ChangePermissionOrLabel perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
+      this.user = user;
     }
 
     public PermissionBackend.ForChange change() {
@@ -212,7 +222,7 @@
 
     @Override
     public int hashCode() {
-      return Objects.hash(perm, impl.resourcePath(), hashForUser(impl.user()));
+      return Objects.hash(perm, impl.resourcePath(), hashForUser(user));
     }
 
     @Override
@@ -223,7 +233,7 @@
       ForChange other = (ForChange) obj;
       return Objects.equals(perm, other.perm)
           && Objects.equals(impl.resourcePath(), other.impl.resourcePath())
-          && usersAreEqual(impl.user(), other.impl.user());
+          && usersAreEqual(user, other.user);
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
new file mode 100644
index 0000000..6018263
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 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.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.access.GerritPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import java.util.Optional;
+
+/**
+ * This signals that some permission check failed. The message is short so it can print on a
+ * single-line in the Git output.
+ */
+public class PermissionDeniedException extends AuthException {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE_PREFIX = "not permitted: ";
+
+  private final GerritPermission permission;
+  private final Optional<String> resource;
+
+  public PermissionDeniedException(GerritPermission permission) {
+    super(MESSAGE_PREFIX + checkNotNull(permission).describeForException());
+    this.permission = permission;
+    this.resource = Optional.empty();
+  }
+
+  public PermissionDeniedException(GerritPermission permission, String resource) {
+    super(
+        MESSAGE_PREFIX
+            + checkNotNull(permission).describeForException()
+            + " on "
+            + checkNotNull(resource));
+    this.permission = permission;
+    this.resource = Optional.of(resource);
+  }
+
+  public String describePermission() {
+    return permission.describeForException();
+  }
+
+  public Optional<String> getResource() {
+    return resource;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 2d2a64d..787bee4 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,7 +29,6 @@
 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.IdentifiedUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
@@ -69,7 +69,6 @@
   private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -83,7 +82,6 @@
       ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
       DefaultRefFilter.Factory refFilterFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.changeControlFactory = changeControlFactory;
@@ -92,28 +90,10 @@
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
     this.refFilterFactory = refFilterFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
     user = who;
     state = ps;
   }
 
-  ProjectControl forUser(CurrentUser who) {
-    ProjectControl r =
-        new ProjectControl(
-            uploadGroups,
-            receiveGroups,
-            permissionFilter,
-            changeControlFactory,
-            permissionBackend,
-            refFilterFactory,
-            identifiedUserFactory,
-            who,
-            state);
-    // Not per-user, and reusing saves lookup time.
-    r.allSections = allSections;
-    return r;
-  }
-
   ForProject asForProject() {
     return new ForProjectImpl();
   }
@@ -138,7 +118,7 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(identifiedUserFactory, this, refName, relevant);
+      ctl = new RefControl(this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
@@ -212,6 +192,10 @@
     return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
   }
 
+  private boolean canAddTagRefs() {
+    return (canPerformOnTagRef(Permission.CREATE) || isAdmin());
+  }
+
   private boolean canCreateChanges() {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
@@ -233,6 +217,26 @@
     return declaredOwner;
   }
 
+  private boolean canPerformOnTagRef(String permissionName) {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.getSection();
+
+      if (section.getName().startsWith(REFS_TAGS)) {
+        Permission permission = section.getPermission(permissionName);
+        if (permission == null) {
+          continue;
+        }
+
+        Boolean can = canPerform(permissionName, section, permission);
+        if (can != null) {
+          return can;
+        }
+      }
+    }
+
+    return false;
+  }
+
   private boolean canPerformOnAnyRef(String permissionName) {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
@@ -241,25 +245,33 @@
         continue;
       }
 
-      for (PermissionRule rule : permission.getRules()) {
-        if (rule.isBlock() || rule.isDeny() || !match(rule)) {
-          continue;
-        }
-
-        // Being in a group that was granted this permission is only an
-        // approximation.  There might be overrides and doNotInherit
-        // that would render this to be false.
-        //
-        if (controlForRef(section.getName()).canPerform(permissionName)) {
-          return true;
-        }
-        break;
+      Boolean can = canPerform(permissionName, section, permission);
+      if (can != null) {
+        return can;
       }
     }
 
     return false;
   }
 
+  private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
+    for (PermissionRule rule : permission.getRules()) {
+      if (rule.isBlock() || rule.isDeny() || !match(rule)) {
+        continue;
+      }
+
+      // Being in a group that was granted this permission is only an
+      // approximation.  There might be overrides and doNotInherit
+      // that would render this to be false.
+      //
+      if (controlForRef(section.getName()).canPerform(permissionName)) {
+        return true;
+      }
+      break;
+    }
+    return null;
+  }
+
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
     boolean canPerform = false;
     Set<String> patterns = allRefPatterns(permission);
@@ -323,21 +335,6 @@
     private String resourcePath;
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      return forUser(user).asForProject().database(db);
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath = "/projects/" + getProjectState().getName();
@@ -395,6 +392,11 @@
     }
 
     @Override
+    public BooleanCondition testCond(ProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm, getUser());
+    }
+
+    @Override
     public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       if (refFilter == null) {
@@ -413,6 +415,8 @@
 
         case CREATE_REF:
           return canAddRefs();
+        case CREATE_TAG_REF:
+          return canAddTagRefs();
         case CREATE_CHANGE:
           return canCreateChanges();
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index 3fee6cf..7c58ccb 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -51,6 +51,21 @@
   CREATE_REF,
 
   /**
+   * Can create at least one tag reference in the project.
+   *
+   * <p>This project level permission only validates the user may create some tag reference within
+   * the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE);
+   * </pre>
+   */
+  CREATE_TAG_REF,
+
+  /**
    * Can create at least one change in the project.
    *
    * <p>This project level permission only validates the user may create a change for some branch
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index cd1f84a..a3c8de5 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,17 +16,17 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
-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.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -41,7 +41,8 @@
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -55,12 +56,7 @@
   private Boolean canForgeCommitter;
   private Boolean isVisible;
 
-  RefControl(
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      ProjectControl projectControl,
-      String ref,
-      PermissionCollection relevant) {
-    this.identifiedUserFactory = identifiedUserFactory;
+  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
@@ -74,14 +70,6 @@
     return projectControl.getUser();
   }
 
-  RefControl forUser(CurrentUser who) {
-    ProjectControl newCtl = projectControl.forUser(who);
-    if (relevant.isUserSpecific()) {
-      return newCtl.controlForRef(refName);
-    }
-    return new RefControl(identifiedUserFactory, newCtl, refName, relevant);
-  }
-
   /** Is this user a ref owner? */
   boolean isOwner() {
     if (owner == null) {
@@ -133,6 +121,12 @@
     return canPerform(Permission.EDIT_TOPIC_NAME, false, true);
   }
 
+  /** @return true if this user can delete changes. */
+  boolean canDeleteChanges(boolean isChangeOwner) {
+    return canPerform(Permission.DELETE_CHANGES)
+        || (isChangeOwner && canPerform(Permission.DELETE_OWN_CHANGES, isChangeOwner, false));
+  }
+
   /** The range of permitted values associated with a label permission. */
   PermissionRange getRange(String permission) {
     return getRange(permission, false);
@@ -386,15 +380,37 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
+      logger.atFine().log(
+          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+              + " because this permission is blocked",
+          getUser().getLoggableName(),
+          permissionName,
+          withForce,
+          projectControl.getProject().getName(),
+          refName);
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+        logger.atFine().log(
+            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+            getUser().getLoggableName(),
+            permissionName,
+            withForce,
+            projectControl.getProject().getName(),
+            refName);
         return true;
       }
     }
 
+    logger.atFine().log(
+        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+        getUser().getLoggableName(),
+        permissionName,
+        withForce,
+        projectControl.getProject().getName(),
+        refName);
     return false;
   }
 
@@ -402,21 +418,6 @@
     private String resourcePath;
 
     @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForRef user(CurrentUser user) {
-      return forUser(user).asForRef().database(db);
-    }
-
-    @Override
-    public ForRef absentUser(Account.Id id) {
-      return user(identifiedUserFactory.create(id));
-    }
-
-    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
@@ -458,7 +459,88 @@
     @Override
     public void check(RefPermission perm) throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted for " + refName);
+        PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
+        switch (perm) {
+          case UPDATE:
+            if (refName.equals(RefNames.REFS_CONFIG)) {
+              pde.setAdvice(
+                  "Configuration changes can only be pushed by project owners\n"
+                      + "who also have 'Push' rights on "
+                      + RefNames.REFS_CONFIG);
+            } else {
+              pde.setAdvice("To push into this reference you need 'Push' rights.");
+            }
+            break;
+          case DELETE:
+            pde.setAdvice(
+                "You need 'Delete Reference' rights or 'Push' rights with the \n"
+                    + "'Force Push' flag set to delete references.");
+            break;
+          case CREATE_CHANGE:
+            // This is misleading in the default permission backend, since "create change" on a
+            // branch is encoded as "push" on refs/for/DESTINATION.
+            pde.setAdvice(
+                "You need 'Create Change' rights to upload code review requests.\n"
+                    + "Verify that you are pushing to the right branch.");
+            break;
+          case CREATE:
+            pde.setAdvice("You need 'Create' rights to create new references.");
+            break;
+          case CREATE_SIGNED_TAG:
+            pde.setAdvice("You need 'Create Signed Tag' rights to push a signed tag.");
+            break;
+          case CREATE_TAG:
+            pde.setAdvice("You need 'Create Tag' rights to push a normal tag.");
+            break;
+          case FORCE_UPDATE:
+            pde.setAdvice(
+                "You need 'Push' rights with 'Force' flag set to do a non-fastforward push.");
+            break;
+          case FORGE_AUTHOR:
+            pde.setAdvice(
+                "You need 'Forge Author' rights to push commits with another user as author.");
+            break;
+          case FORGE_COMMITTER:
+            pde.setAdvice(
+                "You need 'Forge Committer' rights to push commits with another user as committer.");
+            break;
+          case FORGE_SERVER:
+            pde.setAdvice(
+                "You need 'Forge Server' rights to push merge commits authored by the server.");
+            break;
+          case MERGE:
+            pde.setAdvice(
+                "You need 'Push Merge' in addition to 'Push' rights to push merge commits.");
+            break;
+
+          case READ:
+            pde.setAdvice("You need 'Read' rights to fetch or clone this ref.");
+            break;
+
+          case READ_CONFIG:
+            pde.setAdvice("You need 'Read' rights on refs/meta/config to see the configuration.");
+            break;
+          case READ_PRIVATE_CHANGES:
+            pde.setAdvice("You need 'Read Private Changes' to see private changes.");
+            break;
+          case SET_HEAD:
+            pde.setAdvice("You need 'Set HEAD' rights to set the default branch.");
+            break;
+          case SKIP_VALIDATION:
+            pde.setAdvice(
+                "You need 'Forge Author', 'Forge Server', 'Forge Committer'\n"
+                    + "and 'Push Merge' rights to skip validation.");
+            break;
+          case UPDATE_BY_SUBMIT:
+            pde.setAdvice(
+                "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
+            break;
+
+          case WRITE_CONFIG:
+            pde.setAdvice("You need 'Write' rights on refs/meta/config.");
+            break;
+        }
+        throw pde;
       }
     }
 
@@ -474,6 +556,11 @@
       return ok;
     }
 
+    @Override
+    public BooleanCondition testCond(RefPermission perm) {
+      return new PermissionBackendCondition.ForRef(this, perm, getUser());
+    }
+
     private boolean can(RefPermission perm) throws PermissionBackendException {
       switch (perm) {
         case READ:
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 48c8bff..e5392b0 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -26,7 +26,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.IdentityHashMap;
 import java.util.List;
 
@@ -88,7 +87,7 @@
         poison |= srcMap.put(sections.get(i), i) != null;
       }
 
-      Collections.sort(sections, new MostSpecificComparator(ref));
+      sections.sort(new MostSpecificComparator(ref));
 
       int[] srcIdx;
       if (isIdentityTransform(sections, srcMap)) {
diff --git a/java/com/google/gerrit/server/plugins/InstallPlugin.java b/java/com/google/gerrit/server/plugins/InstallPlugin.java
index ee9099e..a79a5a6 100644
--- a/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -16,15 +16,18 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintWriter;
@@ -92,6 +95,28 @@
   }
 
   @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
+  static class Create
+      implements RestCollectionCreateView<TopLevelResource, PluginResource, InstallPluginInput> {
+    private final PluginLoader loader;
+    private final Provider<InstallPlugin> install;
+
+    @Inject
+    Create(PluginLoader loader, Provider<InstallPlugin> install) {
+      this.loader = loader;
+      this.install = install;
+    }
+
+    @Override
+    public Response<PluginInfo> apply(
+        TopLevelResource parentResource, IdString id, InstallPluginInput input) throws Exception {
+      loader.checkRemoteAdminEnabled();
+      return install.get().setName(id.get()).setCreated(true).apply(parentResource, input);
+    }
+  }
+
+  @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
   static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
     private final Provider<InstallPlugin> install;
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 229f394..5b80059 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
@@ -90,7 +91,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private static String getExtension(Path path) {
diff --git a/java/com/google/gerrit/server/plugins/PluginEntry.java b/java/com/google/gerrit/server/plugins/PluginEntry.java
index f7b1e82..3a6c7b2 100644
--- a/java/com/google/gerrit/server/plugins/PluginEntry.java
+++ b/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.plugins;
 
+import static java.util.Comparator.comparing;
+
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Map;
@@ -28,13 +30,7 @@
 public class PluginEntry {
   public static final String ATTR_CHARACTER_ENCODING = "Character-Encoding";
   public static final String ATTR_CONTENT_TYPE = "Content-Type";
-  public static final Comparator<PluginEntry> COMPARATOR_BY_NAME =
-      new Comparator<PluginEntry>() {
-        @Override
-        public int compare(PluginEntry a, PluginEntry b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
+  public static final Comparator<PluginEntry> COMPARATOR_BY_NAME = comparing(PluginEntry::getName);
 
   private static final Map<Object, String> EMPTY_ATTRS = Collections.emptyMap();
   private static final Optional<Long> NO_SIZE = Optional.empty();
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index effd51a..8bc04a3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -274,14 +274,15 @@
   private void attachItem(
       Map<TypeLiteral<?>, DynamicItem<?>> items, @Nullable Injector src, Plugin plugin) {
     for (RegistrationHandle h :
-        PrivateInternals_DynamicTypes.attachItems(src, items, plugin.getName())) {
+        PrivateInternals_DynamicTypes.attachItems(src, plugin.getName(), items)) {
       plugin.add(h);
     }
   }
 
   private void attachSet(
       Map<TypeLiteral<?>, DynamicSet<?>> sets, @Nullable Injector src, Plugin plugin) {
-    for (RegistrationHandle h : PrivateInternals_DynamicTypes.attachSets(src, sets)) {
+    for (RegistrationHandle h :
+        PrivateInternals_DynamicTypes.attachSets(src, plugin.getName(), sets)) {
       plugin.add(h);
     }
   }
@@ -434,7 +435,7 @@
           oi.remove();
           replace(newPlugin, h2, b);
         } else {
-          newPlugin.add(set.add(b.getKey(), b.getProvider()));
+          newPlugin.add(set.add(newPlugin.getName(), b.getKey(), b.getProvider()));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index cad0e1e..8dbea78 100644
--- a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -27,6 +27,7 @@
     requireBinding(Key.get(PluginUser.Factory.class));
     bind(PluginsCollection.class);
     DynamicMap.mapOf(binder(), PLUGIN_KIND);
+    create(PLUGIN_KIND).to(InstallPlugin.Create.class);
     put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
     delete(PLUGIN_KIND).to(DisablePlugin.class);
     get(PLUGIN_KIND, "status").to(GetStatus.class);
diff --git a/java/com/google/gerrit/server/plugins/PluginsCollection.java b/java/com/google/gerrit/server/plugins/PluginsCollection.java
index 9dbc956..7cc006e 100644
--- a/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -27,24 +25,18 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PluginsCollection
-    implements RestCollection<TopLevelResource, PluginResource>, AcceptsCreate<TopLevelResource> {
+public class PluginsCollection implements RestCollection<TopLevelResource, PluginResource> {
 
   private final DynamicMap<RestView<PluginResource>> views;
   private final PluginLoader loader;
   private final Provider<ListPlugins> list;
-  private final Provider<InstallPlugin> install;
 
   @Inject
-  PluginsCollection(
-      DynamicMap<RestView<PluginResource>> views,
-      PluginLoader loader,
-      Provider<ListPlugins> list,
-      Provider<InstallPlugin> install) {
+  public PluginsCollection(
+      DynamicMap<RestView<PluginResource>> views, PluginLoader loader, Provider<ListPlugins> list) {
     this.views = views;
     this.loader = loader;
     this.list = list;
-    this.install = install;
   }
 
   @Override
@@ -67,12 +59,6 @@
   }
 
   @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
-    loader.checkRemoteAdminEnabled();
-    return install.get().setName(id.get()).setCreated(true);
-  }
-
-  @Override
   public DynamicMap<RestView<PluginResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 6ae00e1..f236202 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -46,6 +46,7 @@
   private final String metricsPrefix;
   private final GerritRuntime gerritRuntime;
   protected Class<? extends Module> sysModule;
+  protected Class<? extends Module> batchModule;
   protected Class<? extends Module> sshModule;
   protected Class<? extends Module> httpModule;
 
@@ -91,6 +92,7 @@
     String sysName = main.getValue("Gerrit-Module");
     String sshName = main.getValue("Gerrit-SshModule");
     String httpName = main.getValue("Gerrit-HttpModule");
+    String batchName = main.getValue("Gerrit-BatchModule");
 
     if (!Strings.isNullOrEmpty(sshName) && getApiType() != Plugin.ApiType.PLUGIN) {
       throw new InvalidPluginException(
@@ -99,6 +101,7 @@
     }
 
     try {
+      this.batchModule = load(batchName, classLoader);
       this.sysModule = load(sysName, classLoader);
       this.sshModule = load(sshName, classLoader);
       this.httpModule = load(httpName, classLoader);
@@ -108,7 +111,7 @@
   }
 
   @SuppressWarnings("unchecked")
-  protected static Class<? extends Module> load(String name, ClassLoader pluginLoader)
+  protected static Class<? extends Module> load(@Nullable String name, ClassLoader pluginLoader)
       throws ClassNotFoundException {
     if (Strings.isNullOrEmpty(name)) {
       return null;
@@ -180,6 +183,18 @@
     serverManager = new LifecycleManager();
     serverManager.add(root);
 
+    if (gerritRuntime == GerritRuntime.BATCH) {
+      if (batchModule != null) {
+        sysInjector = root.createChildInjector(root.getInstance(batchModule));
+        serverManager.add(sysInjector);
+      } else {
+        sysInjector = root;
+      }
+
+      serverManager.start();
+      return;
+    }
+
     AutoRegisterModules auto = null;
     if (sysModule == null && sshModule == null && httpModule == null) {
       auto = new AutoRegisterModules(getName(), env, scanner, classLoader);
diff --git a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
index 0bef1e5..4d89482 100644
--- a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
@@ -60,7 +61,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private ServerPluginProvider providerOf(Path srcPath) {
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
index 087a314a..30bd244 100644
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ b/java/com/google/gerrit/server/project/AccountsSection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.PermissionRule;
 import java.util.ArrayList;
 import java.util.List;
@@ -21,14 +22,14 @@
 public class AccountsSection {
   protected List<PermissionRule> sameGroupVisibility;
 
-  public List<PermissionRule> getSameGroupVisibility() {
+  public ImmutableList<PermissionRule> getSameGroupVisibility() {
     if (sameGroupVisibility == null) {
-      sameGroupVisibility = new ArrayList<>();
+      sameGroupVisibility = ImmutableList.of();
     }
-    return sameGroupVisibility;
+    return ImmutableList.copyOf(sameGroupVisibility);
   }
 
   public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = sameGroupVisibility;
+    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
   }
 }
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index 61a7ef2..79eccbb 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -68,6 +68,9 @@
           .put(
               BooleanProjectConfig.REJECT_EMPTY_COMMIT,
               new Mapper(i -> i.rejectEmptyCommit, (i, v) -> i.rejectEmptyCommit = v))
+          .put(
+              BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
+              new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index d840123..c3c76de 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -108,7 +108,11 @@
 
     if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
       final StringBuilder msg = new StringBuilder();
-      msg.append("A Contributor Agreement must be completed before uploading");
+      msg.append("No Contributor Agreement on file for user ")
+          .append(iUser.getNameEmail())
+          .append(" (id=")
+          .append(iUser.getAccountId())
+          .append(")");
       if (canonicalWebUrl != null) {
         msg.append(":\n\n  ");
         msg.append(canonicalWebUrl);
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
index 5d208f3..eb451fd 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheClock.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.Executors;
@@ -53,13 +54,14 @@
       // Start with generation 1 (to avoid magic 0 below).
       generation.set(1);
       executor =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("ProjectCacheClock-%d")
-                  .setDaemon(true)
-                  .setPriority(Thread.MIN_PRIORITY)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("ProjectCacheClock-%d")
+                      .setDaemon(true)
+                      .setPriority(Thread.MIN_PRIORITY)
+                      .build()));
       @SuppressWarnings("unused") // Runnable already handles errors
       Future<?> possiblyIgnoredError =
           executor.scheduleAtFixedRate(
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 1f51fda..dd6ce56 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -146,7 +146,9 @@
     } catch (Exception e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atWarning().withCause(e).log("Cannot read project %s", projectName.get());
-        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        if (e.getCause() != null) {
+          Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        }
         throw new IOException(e);
       }
       logger.atFine().withCause(e).log("Cannot find project %s", projectName.get());
@@ -176,6 +178,7 @@
   @Override
   public void evict(Project.NameKey p) throws IOException {
     if (p != null) {
+      logger.atFine().log("Evict project '%s'", p.get());
       byName.invalidate(p.get());
     }
     indexer.get().index(p);
@@ -267,11 +270,12 @@
 
     @Override
     public ProjectState load(String projectName) throws Exception {
+      logger.atFine().log("Loading project %s", projectName);
       long now = clock.read();
       Project.NameKey key = new Project.NameKey(projectName);
       try (Repository git = mgr.openRepository(key)) {
         ProjectConfig cfg = new ProjectConfig(key);
-        cfg.load(git);
+        cfg.load(key, git);
 
         ProjectState state = projectStateFactory.create(cfg);
         state.initLastCheck(now);
@@ -296,6 +300,7 @@
 
     @Override
     public ImmutableSortedSet<Project.NameKey> load(ListKey key) throws Exception {
+      logger.atFine().log("Loading project list");
       return ImmutableSortedSet.copyOf(mgr.list());
     }
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 7ebbc51..10cf2de 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -19,10 +19,11 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -43,10 +44,11 @@
   public void start() {
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
-      ThreadPoolExecutor pool =
-          new ScheduledThreadPoolExecutor(
-              config.getInt("cache", "projects", "loadThreads", cpus),
-              new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
+      ExecutorService pool =
+          new LoggingContextAwareExecutorService(
+              new ScheduledThreadPoolExecutor(
+                  config.getInt("cache", "projects", "loadThreads", cpus),
+                  new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build()));
       Thread scheduler =
           new Thread(
               () -> {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index ba1a652..bccc415 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.common.data.Permission.isPermission;
 import static com.google.gerrit.reviewdb.client.Project.DEFAULT_SUBMIT_TYPE;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
@@ -25,6 +27,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
+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.GlobalCapability;
@@ -41,6 +44,7 @@
 import com.google.gerrit.common.errors.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -55,7 +59,6 @@
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.mail.Address;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -69,6 +72,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -77,6 +81,8 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
@@ -86,6 +92,7 @@
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
+  public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
@@ -158,7 +165,6 @@
 
   private static final Pattern EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN = Pattern.compile("[, \t]{1,}");
 
-  private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
   private GroupList groupList;
@@ -239,6 +245,20 @@
     this.projectName = projectName;
   }
 
+  public void load(Repository repo) throws IOException, ConfigInvalidException {
+    super.load(projectName, repo);
+  }
+
+  public void load(Repository repo, @Nullable ObjectId revision)
+      throws IOException, ConfigInvalidException {
+    super.load(projectName, repo, revision);
+  }
+
+  public void load(RevWalk rw, @Nullable ObjectId revision)
+      throws IOException, ConfigInvalidException {
+    super.load(projectName, rw, revision);
+  }
+
   public Project.NameKey getName() {
     return projectName;
   }
@@ -402,10 +422,6 @@
     return mimeTypes;
   }
 
-  public GroupReference resolve(AccountGroup group) {
-    return resolve(GroupReference.forGroup(group));
-  }
-
   public GroupReference resolve(GroupReference group) {
     GroupReference groupRef = groupList.resolve(group);
     if (groupRef != null
@@ -441,10 +457,7 @@
     return rulesId;
   }
 
-  /**
-   * @return the maxObjectSizeLimit for this project, if set. Zero if this project doesn't define
-   *     own maxObjectSizeLimit.
-   */
+  /** @return the maxObjectSizeLimit configured on this project, or zero if not configured. */
   public long getMaxObjectSizeLimit() {
     return maxObjectSizeLimit;
   }
@@ -503,6 +516,9 @@
     if (p.getDescription() == null) {
       p.setDescription("");
     }
+    if (revision != null) {
+      p.setConfigRefState(revision.toObjectId().name());
+    }
 
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
@@ -737,7 +753,7 @@
     }
   }
 
-  private List<PermissionRule> loadPermissionRules(
+  private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc,
       String section,
       String subsection,
@@ -746,7 +762,7 @@
       boolean useRange) {
     Permission perm = new Permission(varName);
     loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return perm.getRules();
+    return ImmutableList.copyOf(perm.getRules());
   }
 
   private void loadPermissionRules(
@@ -868,6 +884,8 @@
       }
       label.setAllowPostSubmit(
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
+      label.setIgnoreSelfApproval(
+          rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
       label.setCopyMinScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
@@ -1150,21 +1168,20 @@
 
   private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
     for (NotifyConfig nc : sort(notifySections.values())) {
-      List<String> email = new ArrayList<>();
-      for (GroupReference gr : nc.getGroups()) {
-        if (gr.getUUID() != null) {
-          keepGroups.add(gr.getUUID());
-        }
-        email.add(new PermissionRule(gr).asString(false));
-      }
-      Collections.sort(email);
+      nc.getGroups()
+          .stream()
+          .map(gr -> gr.getUUID())
+          .filter(Objects::nonNull)
+          .forEach(keepGroups::add);
+      List<String> email =
+          nc.getGroups()
+              .stream()
+              .map(gr -> new PermissionRule(gr).asString(false))
+              .sorted()
+              .collect(toList());
 
-      List<String> addrs = new ArrayList<>();
-      for (Address addr : nc.getAddresses()) {
-        addrs.add(addr.toString());
-      }
-      Collections.sort(addrs);
-      email.addAll(addrs);
+      // Separate stream operation so that emails list contains 2 sorted sub-lists.
+      nc.getAddresses().stream().map(Address::toString).sorted().forEach(email::add);
 
       set(rc, NOTIFY, nc.getName(), KEY_HEADER, nc.getHeader(), NotifyConfig.Header.BCC);
       if (email.isEmpty()) {
@@ -1308,6 +1325,13 @@
           rc,
           LABEL,
           name,
+          KEY_IGNORE_SELF_APPROVAL,
+          label.ignoreSelfApproval(),
+          LabelType.DEF_IGNORE_SELF_APPROVAL);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_MIN_SCORE,
           label.isCopyMinScore(),
           LabelType.DEF_COPY_MIN_SCORE);
@@ -1353,6 +1377,11 @@
         values.add(value.format().trim());
       }
       rc.setStringList(LABEL, name, KEY_VALUE, values);
+
+      List<String> refPatterns = label.getRefPatterns();
+      if (refPatterns != null && !refPatterns.isEmpty()) {
+        rc.setStringList(LABEL, name, KEY_BRANCH, refPatterns);
+      }
     }
 
     for (String name : toUnset) {
@@ -1433,10 +1462,8 @@
     validationErrors.add(error);
   }
 
-  private static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
-    ArrayList<T> r = new ArrayList<>(m);
-    Collections.sort(r);
-    return r;
+  private static <T extends Comparable<? super T>> ImmutableList<T> sort(Collection<T> m) {
+    return m.stream().sorted().collect(toImmutableList());
   }
 
   public boolean hasLegacyPermissions() {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index a490f10..a9b19d9 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -40,13 +41,13 @@
 import com.google.gerrit.reviewdb.client.Branch;
 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.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -63,6 +64,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Ref;
@@ -87,6 +89,8 @@
   private final ProjectConfig config;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
+  private final long globalMaxObjectSizeLimit;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   /** Last system time the configuration's revision was examined. */
   private volatile long lastCheckGeneration;
@@ -94,6 +98,8 @@
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
+  // TODO(dborowitz): Delete when the GWT UI gets deleted; in the meantime, don't bother with any
+  // refactoring.
   /** Theme information loaded from site_path/themes. */
   private volatile ThemeInfo theme;
 
@@ -105,14 +111,15 @@
 
   @Inject
   public ProjectState(
-      final SitePaths sitePaths,
-      final ProjectCache projectCache,
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      final GitRepositoryManager gitMgr,
-      final List<CommentLinkInfo> commentLinks,
-      final CapabilityCollection.Factory limitsFactory,
-      @Assisted final ProjectConfig config) {
+      SitePaths sitePaths,
+      ProjectCache projectCache,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr,
+      List<CommentLinkInfo> commentLinks,
+      CapabilityCollection.Factory limitsFactory,
+      TransferConfig transferConfig,
+      @Assisted ProjectConfig config) {
     this.sitePaths = sitePaths;
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -126,6 +133,8 @@
         isAllProjects
             ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
             : null;
+    this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
+    this.inheritProjectMaxObjectSizeLimit = transferConfig.getInheritProjectMaxObjectSizeLimit();
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -223,7 +232,7 @@
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
     try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(git, config.getRevision());
+      cfg.load(getNameKey(), git, config.getRevision());
     } catch (IOException | ConfigInvalidException e) {
       logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
     }
@@ -258,6 +267,60 @@
     }
   }
 
+  public static class EffectiveMaxObjectSizeLimit {
+    public long value;
+    public String summary;
+  }
+
+  private static final String MAY_NOT_SET = "This project may not set a higher limit.";
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_PARENT = "Inherited from parent project '%s'.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_PARENT =
+      "Overridden by parent project '%s'. " + MAY_NOT_SET;
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_GLOBAL = "Inherited from the global config.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_GLOBAL =
+      "Overridden by the global config. " + MAY_NOT_SET;
+
+  public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
+    EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
+
+    result.value = config.getMaxObjectSizeLimit();
+
+    if (inheritProjectMaxObjectSizeLimit) {
+      for (ProjectState parent : parents()) {
+        long parentValue = parent.config.getMaxObjectSizeLimit();
+        if (parentValue > 0 && result.value > 0) {
+          if (parentValue < result.value) {
+            result.value = parentValue;
+            result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+          }
+        } else if (parentValue > 0) {
+          result.value = parentValue;
+          result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+        }
+      }
+    }
+
+    if (globalMaxObjectSizeLimit > 0 && result.value > 0) {
+      if (globalMaxObjectSizeLimit < result.value) {
+        result.value = globalMaxObjectSizeLimit;
+        result.summary = OVERRIDDEN_BY_GLOBAL;
+      }
+    } else if (globalMaxObjectSizeLimit > result.value) {
+      // zero means "no limit", in this case the max is more limiting
+      result.value = globalMaxObjectSizeLimit;
+      result.summary = INHERITED_FROM_GLOBAL;
+    }
+    return result;
+  }
+
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
@@ -394,13 +457,13 @@
     return labelTypes;
   }
 
-  /** All available label types for this change and user. */
-  public LabelTypes getLabelTypes(ChangeNotes notes, CurrentUser user) {
-    return getLabelTypes(notes.getChange().getDest(), user);
+  /** All available label types for this change. */
+  public LabelTypes getLabelTypes(ChangeNotes notes) {
+    return getLabelTypes(notes.getChange().getDest());
   }
 
-  /** All available label types for this branch and user. */
-  public LabelTypes getLabelTypes(Branch.NameKey destination, CurrentUser user) {
+  /** All available label types for this branch. */
+  public LabelTypes getLabelTypes(Branch.NameKey destination) {
     List<LabelType> all = getLabelTypes().getLabelTypes();
 
     List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
@@ -410,7 +473,15 @@
         r.add(l);
       } else {
         for (String refPattern : refs) {
-          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern, user)) {
+          if (refPattern.contains("${")) {
+            logger.atWarning().log(
+                "Ref pattern for label %s in project %s contains illegal expanded parameters: %s."
+                    + " Ref pattern will be ignored.",
+                l, getName(), refPattern);
+            continue;
+          }
+
+          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern)) {
             r.add(l);
             break;
           }
@@ -531,7 +602,11 @@
   }
 
   public ProjectData toProjectData() {
-    return new ProjectData(getProject(), parents().transform(s -> s.getProject().getNameKey()));
+    ProjectData project = null;
+    for (ProjectState state : treeInOrder()) {
+      project = new ProjectData(state.getProject(), Optional.ofNullable(project));
+    }
+    return project;
   }
 
   private String readFile(Path p) throws IOException {
@@ -558,7 +633,7 @@
     return new LabelTypes(Collections.unmodifiableList(all));
   }
 
-  private boolean match(Branch.NameKey destination, String refPattern, CurrentUser user) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), user);
+  private boolean match(Branch.NameKey destination, String refPattern) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), null);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
new file mode 100644
index 0000000..e61c5df
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2018 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.project;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeIdPredicate;
+import com.google.gerrit.server.query.change.CommitPredicate;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+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;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ProjectsConsistencyChecker {
+  @VisibleForTesting public static final int AUTO_CLOSE_MAX_COMMITS_LIMIT = 10000;
+
+  private final GitRepositoryManager repoManager;
+  private final RetryHelper retryHelper;
+  private final Provider<InternalChangeQuery> changeQueryProvider;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  ProjectsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      RetryHelper retryHelper,
+      Provider<InternalChangeQuery> changeQueryProvider,
+      ChangeJson.Factory changeJsonFactory,
+      IndexConfig indexConfig) {
+    this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
+    this.changeQueryProvider = changeQueryProvider;
+    this.changeJsonFactory = changeJsonFactory;
+    this.indexConfig = indexConfig;
+  }
+
+  public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
+      throws IOException, OrmException, RestApiException {
+    CheckProjectResultInfo r = new CheckProjectResultInfo();
+    if (input.autoCloseableChangesCheck != null) {
+      r.autoCloseableChangesCheckResult =
+          checkForAutoCloseableChanges(projectName, input.autoCloseableChangesCheck);
+    }
+    return r;
+  }
+
+  private AutoCloseableChangesCheckResult checkForAutoCloseableChanges(
+      Project.NameKey projectName, AutoCloseableChangesCheckInput input)
+      throws IOException, OrmException, RestApiException {
+    AutoCloseableChangesCheckResult r = new AutoCloseableChangesCheckResult();
+    if (Strings.isNullOrEmpty(input.branch)) {
+      throw new BadRequestException("branch is required");
+    }
+
+    boolean fix = input.fix != null ? input.fix : false;
+
+    if (input.maxCommits != null && input.maxCommits > AUTO_CLOSE_MAX_COMMITS_LIMIT) {
+      throw new BadRequestException(
+          "max commits can at most be set to " + AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    }
+    int maxCommits = input.maxCommits != null ? input.maxCommits : AUTO_CLOSE_MAX_COMMITS_LIMIT;
+
+    // Result that we want to return to the client.
+    List<ChangeInfo> autoCloseableChanges = new ArrayList<>();
+
+    // Remember the change IDs of all changes that we already included into the result, so that we
+    // can avoid including the same change twice.
+    Set<Change.Id> seenChanges = new HashSet<>();
+
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      String branch = RefNames.fullName(input.branch);
+      Ref ref = repo.exactRef(branch);
+      if (ref == null) {
+        throw new UnprocessableEntityException(
+            String.format("branch '%s' not found", input.branch));
+      }
+
+      rw.reset();
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+
+      // Cache the SHA1's of all merged commits. We need this for knowing which commit merged the
+      // change when auto-closing changes by commit.
+      List<ObjectId> mergedSha1s = new ArrayList<>();
+
+      // Cache the Change-Id to commit SHA1 mapping for all Change-Id's that we find in merged
+      // commits. We need this for knowing which commit merged the change when auto-closing
+      // changes by Change-Id.
+      Map<Change.Key, ObjectId> changeIdToMergedSha1 = new HashMap<>();
+
+      // Base predicate which is fixed for every change query.
+      Predicate<ChangeData> basePredicate =
+          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+
+      int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
+
+      // List of predicates by which we want to find open changes for the branch. These predicates
+      // will be combined with the 'or' operator.
+      List<Predicate<ChangeData>> predicates = new ArrayList<>(maxLeafPredicates);
+
+      RevCommit commit;
+      int skippedCommits = 0;
+      int walkedCommits = 0;
+      while ((commit = rw.next()) != null) {
+        if (input.skipCommits != null && skippedCommits < input.skipCommits) {
+          skippedCommits++;
+          continue;
+        }
+
+        if (walkedCommits >= maxCommits) {
+          break;
+        }
+        walkedCommits++;
+
+        ObjectId commitId = commit.copy();
+        mergedSha1s.add(commitId);
+
+        // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
+        List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+
+        // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
+        // the commit.
+        int newPredicatesCount = changeIds.size() + 1;
+
+        // We accumulated the max number of query terms that can be used in one query, execute
+        // the query and start a new one.
+        if (predicates.size() + newPredicatesCount > maxLeafPredicates) {
+          autoCloseableChanges.addAll(
+              executeQueryAndAutoCloseChanges(
+                  basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+          mergedSha1s.clear();
+          changeIdToMergedSha1.clear();
+          predicates.clear();
+
+          if (newPredicatesCount > maxLeafPredicates) {
+            // Whee, a single commit generates more than maxLeafPredicates predicates. Give up.
+            throw new ResourceConflictException(
+                String.format(
+                    "commit %s contains more Change-Ids than we can handle", commit.name()));
+          }
+        }
+
+        changeIds.forEach(
+            changeId -> {
+              // It can happen that there are multiple merged commits with the same Change-Id
+              // footer (e.g. if a change was cherry-picked to a stable branch stable branch which
+              // then got merged back into master, or just by directly pushing several commits
+              // with the same Change-Id). In this case it is hard to say which of the commits
+              // should be used to auto-close an open change with the same Change-Id (and branch).
+              // Possible approaches are:
+              // 1. use the oldest commit with that Change-Id to auto-close the change
+              // 2. use the newest commit with that Change-Id to auto-close the change
+              // Possibility 1. has the disadvantage that the commit may have been merged before
+              // the change was created in which case it is strange how it could auto-close the
+              // change. Also this strategy would require to walk all commits since otherwise we
+              // cannot be sure that we have seen the oldest commit with that Change-Id.
+              // Possibility 2 has the disadvantage that it doesn't produce the same result as if
+              // auto-closing on push would have worked, since on direct push the first commit with
+              // a Change-Id of an open change would have closed that change. Also for this we
+              // would need to consider all commits that are skipped.
+              // Since both possibilities are not perfect and require extra effort we choose the
+              // easiest approach, which is use the newest commit with that Change-Id that we have
+              // seen (this means we ignore skipped commits). This should be okay since the
+              // important thing for callers is that auto-closable changes are closed. Which of the
+              // commits is used to auto-close a change if there are several candidates is of minor
+              // importance and hence can be non-deterministic.
+              Change.Key changeKey = new Change.Key(changeId);
+              if (!changeIdToMergedSha1.containsKey(changeKey)) {
+                changeIdToMergedSha1.put(changeKey, commitId);
+              }
+
+              // Find changes that have a matching Change-Id.
+              predicates.add(new ChangeIdPredicate(changeId));
+            });
+
+        // Find changes that have a matching commit.
+        predicates.add(new CommitPredicate(commit.name()));
+      }
+
+      if (predicates.size() > 0) {
+        // Execute the query with the remaining predicates that were collected.
+        autoCloseableChanges.addAll(
+            executeQueryAndAutoCloseChanges(
+                basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+      }
+    }
+
+    r.autoCloseableChanges = autoCloseableChanges;
+    return r;
+  }
+
+  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+      Predicate<ChangeData> basePredicate,
+      Set<Change.Id> seenChanges,
+      List<Predicate<ChangeData>> predicates,
+      boolean fix,
+      Map<Change.Key, ObjectId> changeIdToMergedSha1,
+      List<ObjectId> mergedSha1s)
+      throws OrmException {
+    if (predicates.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    try {
+      List<ChangeData> queryResult =
+          retryHelper.execute(
+              ActionType.INDEX_QUERY,
+              () -> {
+                // Execute the query.
+                return changeQueryProvider
+                    .get()
+                    .setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                    .query(and(basePredicate, or(predicates)));
+              },
+              OrmException.class::isInstance);
+
+      // Result for this query that we want to return to the client.
+      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+
+      for (ChangeData autoCloseableChange : queryResult) {
+        // Skip changes that we have already processed, either by this query or by
+        // earlier queries.
+        if (seenChanges.add(autoCloseableChange.getId())) {
+          retryHelper.execute(
+              ActionType.CHANGE_UPDATE,
+              () -> {
+                // Auto-close by change
+                if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
+                  autoCloseableChangesByBranch.add(
+                      changeJson(
+                              fix, changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
+                          .format(autoCloseableChange));
+                  return null;
+                }
+
+                // Auto-close by commit
+                for (ObjectId patchSetSha1 :
+                    autoCloseableChange
+                        .patchSets()
+                        .stream()
+                        .map(ps -> ObjectId.fromString(ps.getRevision().get()))
+                        .collect(toSet())) {
+                  if (mergedSha1s.contains(patchSetSha1)) {
+                    autoCloseableChangesByBranch.add(
+                        changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                    break;
+                  }
+                }
+                return null;
+              },
+              OrmException.class::isInstance);
+        }
+      }
+
+      return autoCloseableChangesByBranch;
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, OrmException.class);
+      throw new OrmException(e);
+    }
+  }
+
+  private ChangeJson changeJson(Boolean fix, ObjectId mergedAs) {
+    ChangeJson changeJson = changeJsonFactory.create(ListChangesOption.CHECK);
+    if (fix != null && fix.booleanValue()) {
+      FixInput fixInput = new FixInput();
+      fixInput.expectMergedAs = mergedAs.name();
+      changeJson.fix(fixInput);
+    }
+    return changeJson;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 322d362e..63fda19 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -70,8 +70,8 @@
   boolean fromHeadsOrTags(Project.NameKey project, Repository repo, RevCommit commit) {
     try {
       RefDatabase refdb = repo.getRefDatabase();
-      Collection<Ref> heads = refdb.getRefs(Constants.R_HEADS).values();
-      Collection<Ref> tags = refdb.getRefs(Constants.R_TAGS).values();
+      Collection<Ref> heads = refdb.getRefsByPrefix(Constants.R_HEADS);
+      Collection<Ref> tags = refdb.getRefsByPrefix(Constants.R_TAGS);
       Map<String, Ref> refs = Maps.newHashMapWithExpectedSize(heads.size() + tags.size());
       for (Ref r : Iterables.concat(heads, tags)) {
         refs.put(r.getName(), r);
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index e5951a8..9f1fa4a 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -71,7 +71,7 @@
       RefDatabase refDb = repo.getRefDatabase();
       Iterable<Ref> refs =
           Iterables.concat(
-              refDb.getRefs(Constants.R_HEADS).values(), refDb.getRefs(Constants.R_TAGS).values());
+              refDb.getRefsByPrefix(Constants.R_HEADS), refDb.getRefsByPrefix(Constants.R_TAGS));
       Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
       if (rc != null) {
         refs = Iterables.concat(refs, Collections.singleton(rc));
diff --git a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index cc9fc0d..bd7b7fe 100644
--- a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
@@ -21,6 +22,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final AccountControl accountControl;
 
   public AccountIsVisibleToPredicate(AccountControl accountControl) {
@@ -30,7 +33,11 @@
 
   @Override
   public boolean match(AccountState accountState) throws OrmException {
-    return accountControl.canSee(accountState);
+    boolean canSee = accountControl.canSee(accountState);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visisble account: %s", accountState);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index e7ffd5e..41d53a2 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -19,6 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.LimitPredicate;
@@ -120,12 +121,17 @@
   public Predicate<AccountState> cansee(String change)
       throws QueryParseException, OrmException, PermissionBackendException {
     ChangeNotes changeNotes = args.changeFinder.findOne(change);
-    if (changeNotes == null
-        || !args.permissionBackend
-            .user(args.getUser())
-            .database(args.db)
-            .change(changeNotes)
-            .test(ChangePermission.READ)) {
+    if (changeNotes == null) {
+      throw error(String.format("change %s not found", change));
+    }
+
+    try {
+      args.permissionBackend
+          .user(args.getUser())
+          .database(args.db)
+          .change(changeNotes)
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw error(String.format("change %s not found", change));
     }
 
@@ -218,7 +224,12 @@
   }
 
   private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
+    try {
+      args.permissionBackend.user(args.getUser()).check(GlobalPermission.MODIFY_ACCOUNT);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   private boolean checkedCanSeeSecondaryEmails() {
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index b008092..fb3549c 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
@@ -40,13 +41,16 @@
   @Override
   public boolean match(AccountState accountState) throws OrmException {
     try {
-      return permissionBackend
+      permissionBackend
           .absentUser(accountState.getAccount().getId())
           .database(db)
           .change(changeNotes)
-          .test(ChangePermission.READ);
+          .check(ChangePermission.READ);
+      return true;
     } catch (PermissionBackendException e) {
       throw new OrmException("Failed to check if account can see change", e);
+    } catch (AuthException e) {
+      return false;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 0a2f219..7fb0989 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -51,7 +51,6 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -325,7 +324,7 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, project, id, null, null);
+            null, null, null, project, id, null, null);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
     return cd;
   }
@@ -338,7 +337,6 @@
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
   private final GitRepositoryManager repoManager;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
   private final NotesMigration notesMigration;
@@ -407,7 +405,6 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      IdentifiedUser.GenericFactory userFactory,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       NotesMigration notesMigration,
@@ -428,7 +425,6 @@
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
     this.repoManager = repoManager;
-    this.userFactory = userFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
     this.notesMigration = notesMigration;
@@ -590,7 +586,7 @@
       } catch (IOException e) {
         throw new OrmException("project state not available", e);
       }
-      labelTypes = state.getLabelTypes(change().getDest(), userFactory.create(change().getOwner()));
+      labelTypes = state.getLabelTypes(change().getDest());
     }
     return labelTypes;
   }
@@ -633,13 +629,7 @@
         try {
           currentApprovals =
               ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(
-                      db,
-                      notes(),
-                      userFactory.create(c.getOwner()),
-                      c.currentPatchSetId(),
-                      null,
-                      null));
+                  approvalsUtil.byPatchSet(db, notes(), c.currentPatchSetId(), null, null));
         } catch (OrmException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
@@ -1042,12 +1032,12 @@
       editsByUser = new HashMap<>();
       Change.Id id = checkNotNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
-        for (Map.Entry<String, Ref> e :
-            repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
-          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
-            Account.Id accountId = Account.Id.fromRefPart(e.getKey());
+        for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
+          String name = ref.getName().substring(RefNames.REFS_USERS.length());
+          if (id.equals(Change.Id.fromEditRefPart(name))) {
+            Account.Id accountId = Account.Id.fromRefPart(name);
             if (accountId != null) {
-              editsByUser.put(accountId, e.getValue());
+              editsByUser.put(accountId, ref);
             }
           }
         }
@@ -1099,14 +1089,15 @@
   public boolean isReviewedBy(Account.Id accountId) throws OrmException {
     Collection<String> stars = stars(accountId);
 
-    if (stars.contains(
-        StarredChangesUtil.REVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return true;
-    }
+    PatchSet ps = currentPatchSet();
+    if (ps != null) {
+      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+        return true;
+      }
 
-    if (stars.contains(
-        StarredChangesUtil.UNREVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return false;
+      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+        return false;
+      }
     }
 
     return reviewedBy().contains(accountId);
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 346ac8e..f81ea15 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -73,37 +74,39 @@
     try {
       ProjectState projectState = projectCache.checkedGet(cd.project());
       if (projectState == null) {
-        logger.atInfo().log("No such project: %s", cd.project());
+        logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
         return false;
       }
       if (!projectState.statePermitsRead()) {
+        logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
         return false;
       }
     } catch (IOException e) {
       throw new OrmException("unable to read project state", e);
     }
 
-    boolean visible;
     PermissionBackend.WithUser withUser =
         user.isIdentifiedUser()
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(anonymousUserProvider.get());
     try {
-      visible = withUser.indexedChange(cd, notes).database(db).test(ChangePermission.READ);
+      withUser.indexedChange(cd, notes).database(db).check(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
         logger.atWarning().withCause(e).log(
-            "Skipping change %s because the corresponding repository was not found", cd.getId());
+            "Filter out change %s because the corresponding repository %s was not found",
+            cd, cd.project());
         return false;
       }
       throw new OrmException("unable to check permissions on change " + cd.getId(), e);
+    } catch (AuthException e) {
+      logger.atFine().log("Filter out non-visisble change: %s", cd);
+      return false;
     }
-    if (visible) {
-      cd.cacheVisibleTo(user);
-      return true;
-    }
-    return false;
+
+    cd.cacheVisibleTo(user);
+    return true;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 3113504..5667869 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -65,7 +66,6 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -658,6 +658,21 @@
   }
 
   @Operator
+  public Predicate<ChangeData> repository(String name) {
+    return project(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> repositories(String name) {
+    return projects(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> parentrepository(String name) {
+    return parentproject(name);
+  }
+
+  @Operator
   public Predicate<ChangeData> branch(String name) {
     if (name.startsWith("^")) {
       return ref("^" + RefNames.fullName(name.substring(1)));
@@ -1092,7 +1107,7 @@
   public Predicate<ChangeData> query(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
-      q.load(git);
+      q.load(args.allUsersName, git);
       String query = q.getQueryList().getQuery(name);
       if (query != null) {
         return parse(query);
@@ -1116,7 +1131,7 @@
   public Predicate<ChangeData> destination(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
-      d.load(git);
+      d.load(args.allUsersName, git);
       Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
diff --git a/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
index 9daf886..42f5b13 100644
--- a/java/com/google/gerrit/server/query/change/ConflictKey.java
+++ b/java/com/google/gerrit/server/query/change/ConflictKey.java
@@ -20,10 +20,10 @@
 import com.google.common.base.Enums;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -61,7 +61,7 @@
 
   public abstract boolean contentMerge();
 
-  public static enum Serializer implements CacheSerializer<ConflictKey> {
+  public enum Serializer implements CacheSerializer<ConflictKey> {
     INSTANCE;
 
     private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
diff --git a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
index 0b8c5ee..426c5d6 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 19fd4a1..54e22f3 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -125,10 +126,13 @@
       PermissionBackend.ForChange perm =
           permissionBackend.absentUser(approver).database(dbProvider).change(cd);
       ProjectState projectState = projectCache.checkedGet(cd.project());
-      return projectState != null
-          && projectState.statePermitsRead()
-          && perm.test(ChangePermission.READ);
-    } catch (PermissionBackendException | IOException e) {
+      if (projectState == null || !projectState.statePermitsRead()) {
+        return false;
+      }
+
+      perm.check(ChangePermission.READ);
+      return true;
+    } catch (PermissionBackendException | IOException | AuthException e) {
       return false;
     }
   }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6e63a32..495d27c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -138,7 +138,21 @@
   }
 
   public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
+    return query(byBranchKeyPred(branch, key));
+  }
+
+  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key)
+      throws OrmException {
+    return query(and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchKeyOpenPred(
+      Project.NameKey project, String branch, Change.Key key) {
+    return and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open());
+  }
+
+  private static Predicate<ChangeData> byBranchKeyPred(Branch.NameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.getParentKey()), change(key));
   }
 
   public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
@@ -184,7 +198,7 @@
       throws OrmException, IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
-    for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
       String r = ref.getName();
       if ((lastPrefix != null && r.startsWith(lastPrefix))
           || !hashes.contains(ref.getObjectId().name())) {
@@ -264,13 +278,28 @@
 
   public List<ChangeData> byBranchCommit(String project, String branch, String hash)
       throws OrmException {
-    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
+    return query(byBranchCommitPred(project, branch, hash));
   }
 
   public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
     return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
   }
 
+  public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash)
+      throws OrmException {
+    return query(and(byBranchCommitPred(project, branch, hash), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchCommitOpenPred(
+      Project.NameKey project, String branch, String hash) {
+    return and(byBranchCommitPred(project.get(), branch, hash), open());
+  }
+
+  private static Predicate<ChangeData> byBranchCommitPred(
+      String project, String branch, String hash) {
+    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+  }
+
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index dc57a9b..dbbf367 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -237,7 +237,7 @@
       ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
       throws OrmException, IOException {
     LabelTypes labelTypes = d.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
+    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change(), d.notes());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 3764a98..f694904 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.util.RegexListSearcher;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index f4e979c..667c630 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -17,8 +17,8 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gwtorm.server.OrmException;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index ffa59c2..144a81c 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<InternalGroup> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final GroupControl.GenericFactory groupControlFactory;
   protected final CurrentUser user;
 
@@ -37,7 +40,11 @@
   @Override
   public boolean match(InternalGroup group) throws OrmException {
     try {
-      return groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      boolean canSee = groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      if (!canSee) {
+        logger.atFine().log("Filter out non-visisble group: %s", group.getGroupUUID());
+      }
+      return canSee;
     } catch (NoSuchGroupException e) {
       // Ignored
       return false;
diff --git a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
index 24209c7..b1c5af0 100644
--- a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class ProjectIsVisibleToPredicate extends IsVisibleToPredicate<ProjectData> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final PermissionBackend permissionBackend;
   protected final CurrentUser user;
 
@@ -36,13 +39,19 @@
   @Override
   public boolean match(ProjectData pd) throws OrmException {
     if (!pd.getProject().getState().permitsRead()) {
+      logger.atFine().log("Filter out non-readable project: %s", pd);
       return false;
     }
 
-    return permissionBackend
-        .user(user)
-        .project(pd.getProject().getNameKey())
-        .testOrFalse(ProjectPermission.READ);
+    boolean canSee =
+        permissionBackend
+            .user(user)
+            .project(pd.getProject().getNameKey())
+            .testOrFalse(ProjectPermission.ACCESS);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visible project: %s", pd);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index b4f56d4..2e406aa 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.index.project.ProjectPredicate;
@@ -34,5 +35,9 @@
     return new ProjectPredicate(ProjectField.DESCRIPTION, description);
   }
 
+  public static Predicate<ProjectData> state(ProjectState state) {
+    return new ProjectPredicate(ProjectField.STATE, state.name());
+  }
+
   private ProjectPredicates() {}
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index be7ea22..872d3e0 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
@@ -60,6 +61,23 @@
     return ProjectPredicates.description(description);
   }
 
+  @Operator
+  public Predicate<ProjectData> state(String state) throws QueryParseException {
+    if (Strings.isNullOrEmpty(state)) {
+      throw error("state operator requires a value");
+    }
+    ProjectState parsedState;
+    try {
+      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
+    } catch (IllegalArgumentException e) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    if (parsedState == ProjectState.HIDDEN) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    return ProjectPredicates.state(parsedState);
+  }
+
   @Override
   protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
     // Adapt the capacity of this list when adding more default predicates.
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index ff114fae..251c7c1 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -12,11 +12,13 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/util/cli",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
         "//lib:blame-cache",
diff --git a/java/com/google/gerrit/server/restapi/access/AccessCollection.java b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
index d4528c5..8ae2ce7 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessCollection.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
@@ -30,7 +30,7 @@
   private final DynamicMap<RestView<AccessResource>> views;
 
   @Inject
-  AccessCollection(Provider<ListAccess> list, DynamicMap<RestView<AccessResource>> views) {
+  public AccessCollection(Provider<ListAccess> list, DynamicMap<RestView<AccessResource>> views) {
     this.list = list;
     this.views = views;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index e6fd037..370833c 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -39,32 +38,28 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class AccountsCollection
-    implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
+public class AccountsCollection implements RestCollection<TopLevelResource, AccountResource> {
   private final Provider<CurrentUser> self;
   private final AccountResolver resolver;
   private final AccountControl.Factory accountControlFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final Provider<QueryAccounts> list;
   private final DynamicMap<RestView<AccountResource>> views;
-  private final CreateAccount.Factory createAccountFactory;
 
   @Inject
-  AccountsCollection(
+  public AccountsCollection(
       Provider<CurrentUser> self,
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
       IdentifiedUser.GenericFactory userFactory,
       Provider<QueryAccounts> list,
-      DynamicMap<RestView<AccountResource>> views,
-      CreateAccount.Factory createAccountFactory) {
+      DynamicMap<RestView<AccountResource>> views) {
     this.self = self;
     this.resolver = resolver;
     this.accountControlFactory = accountControlFactory;
     this.userFactory = userFactory;
     this.list = list;
     this.views = views;
-    this.createAccountFactory = createAccountFactory;
   }
 
   @Override
@@ -158,9 +153,4 @@
   public DynamicMap<RestView<AccountResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateAccount create(TopLevelResource parent, IdString username) {
-    return createAccountFactory.create(username.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index ab06e25..4bdf52c 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -46,7 +46,8 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class AddSshKey implements RestModifyView<AccountResource, SshKeyInput> {
+public class AddSshKey
+    implements RestCollectionModifyView<AccountResource, AccountResource.SshKey, SshKeyInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index ec16e2b..07b1214 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -71,10 +71,12 @@
     }
 
     GlobalOrPluginPermission perm = parse(id);
-    if (permissionBackend.user(target).test(perm)) {
+    try {
+      permissionBackend.absentUser(target.getAccountId()).check(perm);
       return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
+    } catch (AuthException e) {
+      throw new ResourceNotFoundException(id);
     }
-    throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 404b3d3..0e8eb70 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -29,9 +29,10 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
@@ -47,12 +49,13 @@
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.ssh.SshKeyCache;
 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.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -61,11 +64,9 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-public class CreateAccount implements RestModifyView<TopLevelResource, AccountInput> {
-  public interface Factory {
-    CreateAccount create(String username);
-  }
-
+@Singleton
+public class CreateAccount
+    implements RestCollectionCreateView<TopLevelResource, AccountResource, AccountInput> {
   private final Sequences seq;
   private final GroupsCollection groupsCollection;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
@@ -75,7 +76,6 @@
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final Provider<GroupsUpdate> groupsUpdate;
   private final OutgoingEmailValidator validator;
-  private final String username;
 
   @Inject
   CreateAccount(
@@ -87,8 +87,7 @@
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       @UserInitiated Provider<GroupsUpdate> groupsUpdate,
-      OutgoingEmailValidator validator,
-      @Assisted String username) {
+      OutgoingEmailValidator validator) {
     this.seq = seq;
     this.groupsCollection = groupsCollection;
     this.authorizedKeys = authorizedKeys;
@@ -98,43 +97,42 @@
     this.externalIdCreators = externalIdCreators;
     this.groupsUpdate = groupsUpdate;
     this.validator = validator;
-    this.username = username;
   }
 
   @Override
-  public Response<AccountInfo> apply(TopLevelResource rsrc, @Nullable AccountInput input)
+  public Response<AccountInfo> apply(
+      TopLevelResource rsrc, IdString id, @Nullable AccountInput input)
       throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
-    return apply(input != null ? input : new AccountInput());
+          OrmException, IOException, ConfigInvalidException, PermissionBackendException {
+    return apply(id, input != null ? input : new AccountInput());
   }
 
-  public Response<AccountInfo> apply(AccountInput input)
+  public Response<AccountInfo> apply(IdString id, AccountInput input)
       throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
+          OrmException, IOException, ConfigInvalidException, PermissionBackendException {
+    String username = id.get();
     if (input.username != null && !username.equals(input.username)) {
       throw new BadRequestException("username must match URL");
     }
-
     if (!ExternalId.isValidUsername(username)) {
-      throw new BadRequestException(
-          "Username '" + username + "' must contain only letters, numbers, _, - or .");
+      throw new BadRequestException("Invalid username '" + username + "'");
     }
 
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
-    Account.Id id = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
     List<ExternalId> extIds = new ArrayList<>();
 
     if (input.email != null) {
       if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
-      extIds.add(ExternalId.createEmail(id, input.email));
+      extIds.add(ExternalId.createEmail(accountId, input.email));
     }
 
-    extIds.add(ExternalId.createUsername(username, id, input.httpPassword));
+    extIds.add(ExternalId.createUsername(username, accountId, input.httpPassword));
     for (AccountExternalIdCreator c : externalIdCreators) {
-      extIds.addAll(c.create(id, username, input.email));
+      extIds.addAll(c.create(accountId, username, input.email));
     }
 
     try {
@@ -142,7 +140,7 @@
           .get()
           .insert(
               "Create Account via API",
-              id,
+              accountId,
               u -> u.setFullName(input.name).setPreferredEmail(input.email).addExternalIds(extIds));
     } catch (DuplicateExternalIdKeyException e) {
       if (e.getDuplicateKey().isScheme(SCHEME_USERNAME)) {
@@ -159,7 +157,7 @@
 
     for (AccountGroup.UUID groupUuid : groups) {
       try {
-        addGroupMember(groupUuid, id);
+        addGroupMember(groupUuid, accountId);
       } catch (NoSuchGroupException e) {
         throw new UnprocessableEntityException(String.format("Group %s not found", groupUuid));
       }
@@ -167,7 +165,7 @@
 
     if (input.sshKey != null) {
       try {
-        authorizedKeys.addKey(id, input.sshKey);
+        authorizedKeys.addKey(accountId, input.sshKey);
         sshKeyCache.evict(username);
       } catch (InvalidSshKeyException e) {
         throw new BadRequestException(e.getMessage());
@@ -175,7 +173,7 @@
     }
 
     AccountLoader loader = infoLoader.create(true);
-    AccountInfo info = loader.get(id);
+    AccountInfo info = loader.get(accountId);
     loader.fill();
     return Response.created(info);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index abc6dd9..e4e8525 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -22,11 +22,12 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 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.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
@@ -43,17 +44,15 @@
 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.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
+@Singleton
+public class CreateEmail
+    implements RestCollectionCreateView<AccountResource, AccountResource.Email, EmailInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    CreateEmail create(String email);
-  }
-
   private final Provider<CurrentUser> self;
   private final Realm realm;
   private final PermissionBackend permissionBackend;
@@ -61,7 +60,6 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
-  private final String email;
   private final boolean isDevMode;
 
   @Inject
@@ -73,8 +71,7 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator,
-      @Assisted String email) {
+      OutgoingEmailValidator validator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -82,12 +79,11 @@
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
     this.validator = validator;
-    this.email = email != null ? email.trim() : null;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
 
   @Override
-  public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
+  public Response<EmailInfo> apply(AccountResource rsrc, IdString id, EmailInput input)
       throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
     if (input == null) {
@@ -102,13 +98,15 @@
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
 
-    return apply(rsrc.getUser(), input);
+    return apply(rsrc.getUser(), id, input);
   }
 
   /** To be used from plugins that want to create emails without permission checks. */
-  public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
+  public Response<EmailInfo> apply(IdentifiedUser user, IdString id, EmailInput input)
       throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
+    String email = id.get().trim();
+
     if (input == null) {
       input = new EmailInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
new file mode 100644
index 0000000..26d6cf4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -0,0 +1,211 @@
+// Copyright (C) 2018 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.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Comment;
+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.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.HasDraftByPredicate;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdate.Factory;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftComments
+    implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
+
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> userProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final Provider<CommentJson> commentJsonProvider;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  DeleteDraftComments(
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> userProvider,
+      Factory batchUpdateFactory,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeJson.Factory changeJsonFactory,
+      Provider<CommentJson> commentJsonProvider,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache) {
+    this.db = db;
+    this.userProvider = userProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryProvider = queryProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeJsonFactory = changeJsonFactory;
+    this.commentJsonProvider = commentJsonProvider;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public ImmutableList<DeletedDraftCommentInfo> apply(
+      AccountResource rsrc, DeleteDraftCommentsInput input)
+      throws RestApiException, OrmException, UpdateException {
+    CurrentUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (!rsrc.getUser().hasSameAccountId(user)) {
+      // Disallow even for admins or users with Modify Account. Drafts are not like preferences or
+      // other account info; there is no way even for admins to read or delete another user's drafts
+      // using the normal draft endpoints under the change resource, so disallow it here as well.
+      // (Admins may still call this endpoint with impersonation, but in that case it would pass the
+      // hasSameAccountId check.)
+      throw new AuthException("Cannot delete drafts of other user");
+    }
+
+    CommentFormatter commentFormatter = commentJsonProvider.get().newCommentFormatter();
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    Timestamp now = TimeUtil.nowTs();
+    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+    List<Op> ops = new ArrayList<>();
+    for (ChangeData cd :
+        queryProvider
+            .get()
+            // Don't attempt to mutate any changes the user can't currently see.
+            .enforceVisibility(true)
+            .query(predicate(accountId, input))) {
+      BatchUpdate update =
+          updates.computeIfAbsent(
+              cd.project(), p -> batchUpdateFactory.create(db.get(), p, rsrc.getUser(), now));
+      Op op = new Op(commentFormatter, accountId);
+      update.addOp(cd.getId(), op);
+      ops.add(op);
+    }
+
+    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
+    // all updates from this operation only happen in All-Users and thus are fully atomic, so
+    // allowing partial failure would have little value.
+    batchUpdateFactory.execute(updates.values(), BatchUpdateListener.NONE, false);
+
+    return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+  }
+
+  private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
+      throws BadRequestException {
+    Predicate<ChangeData> hasDraft = new HasDraftByPredicate(accountId);
+    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
+      return hasDraft;
+    }
+    try {
+      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+    } catch (QueryParseException e) {
+      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final CommentFormatter commentFormatter;
+    private final Account.Id accountId;
+    private DeletedDraftCommentInfo result;
+
+    Op(CommentFormatter commentFormatter, Id accountId) {
+      this.commentFormatter = commentFormatter;
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchListNotAvailableException, PermissionBackendException {
+      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+      boolean dirty = false;
+      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), accountId)) {
+        dirty = true;
+        PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), c.key.patchSetId);
+        setCommentRevId(
+            c, patchListCache, ctx.getChange(), psUtil.get(ctx.getDb(), ctx.getNotes(), psId));
+        commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(commentFormatter.format(c));
+      }
+      if (dirty) {
+        result = new DeletedDraftCommentInfo();
+        result.change =
+            changeJsonFactory
+                .create(ListChangesOption.SKIP_MERGEABLE)
+                .format(changeDataFactory.create(ctx.getDb(), ctx.getNotes()));
+        result.deleted = comments.build();
+      }
+      return dirty;
+    }
+
+    @Nullable
+    DeletedDraftCommentInfo getResult() {
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
index 8694da0..434b9d6 100644
--- a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -33,27 +32,22 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class EmailsCollection
-    implements ChildCollection<AccountResource, AccountResource.Email>,
-        AcceptsCreate<AccountResource> {
+public class EmailsCollection implements ChildCollection<AccountResource, AccountResource.Email> {
   private final DynamicMap<RestView<AccountResource.Email>> views;
   private final GetEmails list;
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
-  private final CreateEmail.Factory createEmailFactory;
 
   @Inject
   EmailsCollection(
       DynamicMap<RestView<AccountResource.Email>> views,
       GetEmails list,
       Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      CreateEmail.Factory createEmailFactory) {
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.list = list;
     this.self = self;
     this.permissionBackend = permissionBackend;
-    this.createEmailFactory = createEmailFactory;
   }
 
   @Override
@@ -85,9 +79,4 @@
   public DynamicMap<RestView<Email>> views() {
     return views;
   }
-
-  @Override
-  public CreateEmail create(AccountResource parent, IdString email) {
-    return createEmailFactory.create(email.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAccount.java b/java/com/google/gerrit/server/restapi/account/GetAccount.java
index 0d8e25e..6b73ae3b 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAccount.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public AccountInfo apply(AccountResource rsrc) throws OrmException {
+  public AccountInfo apply(AccountResource rsrc) throws OrmException, PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getUser().getAccountId());
     loader.fill();
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index dced4d7..edcbc35 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.config.AgreementJson;
 import com.google.inject.Inject;
@@ -60,7 +61,8 @@
   }
 
   @Override
-  public List<AgreementInfo> apply(AccountResource resource) throws RestApiException {
+  public List<AgreementInfo> apply(AccountResource resource)
+      throws RestApiException, PermissionBackendException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
     }
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index d2236fd..7889f6e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -85,7 +85,7 @@
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
     if (!self.get().hasSameAccountId(resource.getUser())) {
       perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      perm = permissionBackend.user(resource.getUser());
+      perm = permissionBackend.absentUser(resource.getUser().getAccountId());
     }
 
     Map<String, Object> have = new LinkedHashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index de9928c..97d0c60 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -14,24 +14,21 @@
 
 package com.google.gerrit.server.restapi.account;
 
-import com.google.common.base.Throwables;
-import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.EnumSet;
 
 @Singleton
 public class GetDetail implements RestReadView<AccountResource> {
-
   private final InternalAccountDirectory directory;
 
   @Inject
@@ -40,26 +37,13 @@
   }
 
   @Override
-  public AccountDetailInfo apply(AccountResource rsrc) throws OrmException {
+  public AccountDetailInfo apply(AccountResource rsrc)
+      throws OrmException, PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
     AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
     info.registeredOn = a.getRegisteredOn();
     info.inactive = !a.isActive() ? true : null;
-    try {
-      directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
-    } catch (DirectoryException e) {
-      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      throw new OrmException(e);
-    }
+    directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     return info;
   }
-
-  public static class AccountDetailInfo extends AccountInfo {
-    public Timestamp registeredOn;
-    public Boolean inactive;
-
-    public AccountDetailInfo(Integer id) {
-      super(id);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
index a8d14f6..40201a8 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
@@ -50,7 +50,7 @@
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc)
       throws RestApiException, ConfigInvalidException, IOException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 63d042c..ed3347f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -25,10 +28,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
+import java.util.Objects;
 
 @Singleton
 public class GetEmails implements RestReadView<AccountResource> {
@@ -44,27 +45,22 @@
   @Override
   public List<EmailInfo> apply(AccountResource rsrc)
       throws AuthException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
+    return rsrc.getUser()
+        .getEmailAddresses()
+        .stream()
+        .filter(Objects::nonNull)
+        .map(e -> toEmailInfo(rsrc, e))
+        .sorted(comparing((EmailInfo e) -> e.email))
+        .collect(toList());
+  }
 
-    List<EmailInfo> emails = new ArrayList<>();
-    for (String email : rsrc.getUser().getEmailAddresses()) {
-      if (email != null) {
-        EmailInfo e = new EmailInfo();
-        e.email = email;
-        e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-        emails.add(e);
-      }
-    }
-    Collections.sort(
-        emails,
-        new Comparator<EmailInfo>() {
-          @Override
-          public int compare(EmailInfo a, EmailInfo b) {
-            return a.email.compareTo(b.email);
-          }
-        });
-    return emails;
+  private static EmailInfo toEmailInfo(AccountResource rsrc, String email) {
+    EmailInfo e = new EmailInfo();
+    e.email = email;
+    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
+    return e;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetGroups.java b/java/com/google/gerrit/server/restapi/account/GetGroups.java
index 486a151..ad9746e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetGroups.java
+++ b/java/com/google/gerrit/server/restapi/account/GetGroups.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.GroupJson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -42,7 +43,8 @@
   }
 
   @Override
-  public List<GroupInfo> apply(AccountResource resource) throws OrmException {
+  public List<GroupInfo> apply(AccountResource resource)
+      throws OrmException, PermissionBackendException {
     IdentifiedUser user = resource.getUser();
     Account.Id userId = user.getAccountId();
     Set<AccountGroup.UUID> knownGroups = user.getEffectiveGroups().getKnownGroups();
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 112bb24..61021be 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,11 +38,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -67,31 +65,28 @@
 
     Account.Id accountId = rsrc.getUser().getAccountId();
     AccountState account = accounts.get(accountId).orElseThrow(ResourceNotFoundException::new);
-    List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
-    for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
-        account.getProjectWatches().entrySet()) {
-      ProjectWatchInfo pwi = new ProjectWatchInfo();
-      pwi.filter = e.getKey().filter();
-      pwi.project = e.getKey().project().get();
-      pwi.notifyAbandonedChanges = toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
-      pwi.notifyNewChanges = toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
-      pwi.notifyNewPatchSets = toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
-      pwi.notifySubmittedChanges = toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
-      pwi.notifyAllComments = toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
-      projectWatchInfos.add(pwi);
-    }
-    Collections.sort(
-        projectWatchInfos,
-        new Comparator<ProjectWatchInfo>() {
-          @Override
-          public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
-            return ComparisonChain.start()
-                .compare(pwi1.project, pwi2.project)
-                .compare(Strings.nullToEmpty(pwi1.filter), Strings.nullToEmpty(pwi2.filter))
-                .result();
-          }
-        });
-    return projectWatchInfos;
+    return account
+        .getProjectWatches()
+        .entrySet()
+        .stream()
+        .map(e -> toProjectWatchInfo(e.getKey(), e.getValue()))
+        .sorted(
+            comparing((ProjectWatchInfo pwi) -> pwi.project)
+                .thenComparing(pwi -> Strings.nullToEmpty(pwi.filter)))
+        .collect(toList());
+  }
+
+  private static ProjectWatchInfo toProjectWatchInfo(
+      ProjectWatchKey key, ImmutableSet<NotifyType> watchTypes) {
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.filter = key.filter();
+    pwi.project = key.project().get();
+    pwi.notifyAbandonedChanges = toBoolean(watchTypes.contains(NotifyType.ABANDONED_CHANGES));
+    pwi.notifyNewChanges = toBoolean(watchTypes.contains(NotifyType.NEW_CHANGES));
+    pwi.notifyNewPatchSets = toBoolean(watchTypes.contains(NotifyType.NEW_PATCHSETS));
+    pwi.notifySubmittedChanges = toBoolean(watchTypes.contains(NotifyType.SUBMITTED_CHANGES));
+    pwi.notifyAllComments = toBoolean(watchTypes.contains(NotifyType.ALL_COMMENTS));
+    return pwi;
   }
 
   private static Boolean toBoolean(boolean value) {
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index ca5f08e..9b012f7 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -43,6 +43,7 @@
     DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
     DynamicMap.mapOf(binder(), STAR_KIND);
 
+    create(ACCOUNT_KIND).to(CreateAccount.class);
     put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "detail").to(GetDetail.class);
@@ -58,18 +59,19 @@
     put(ACCOUNT_KIND, "active").to(PutActive.class);
     delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
     child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
+    create(EMAIL_KIND).to(CreateEmail.class);
     get(EMAIL_KIND).to(GetEmail.class);
     put(EMAIL_KIND).to(PutEmail.class);
     delete(EMAIL_KIND).to(DeleteEmail.class);
     put(EMAIL_KIND, "preferred").to(PutPreferred.class);
     put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
-    post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
     get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
     post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
     post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
 
+    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
+    postOnCollection(SSH_KEY_KIND).to(AddSshKey.class);
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
@@ -93,6 +95,7 @@
     put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
 
     child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
+    create(STARRED_CHANGE_KIND).to(StarredChanges.Create.class);
     put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
     bind(StarredChanges.Create.class);
@@ -104,8 +107,10 @@
     get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
     post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
 
-    factory(CreateAccount.Factory.class);
-    factory(CreateEmail.Factory.class);
+    post(ACCOUNT_KIND, "drafts:delete").to(DeleteDraftComments.class);
+
+    // The gpgkeys REST endpoints are bound via GpgApiModule.
+
     factory(AccountsUpdate.Factory.class);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 3f1a833..0d8a816 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index e42e5d1..4bbd8fc 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -119,6 +120,7 @@
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public static String generate() {
     byte[] rand = new byte[LEN];
     rng.nextBytes(rand);
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 51d28ed..a828987 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -71,7 +71,7 @@
   public Response<String> apply(AccountResource.Email rsrc, Input input)
       throws RestApiException, OrmException, IOException, PermissionBackendException,
           ConfigInvalidException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 8784d23..2c0512c 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -171,9 +172,15 @@
       fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
       fillOptions.add(FillOptions.EMAIL);
 
-      if (modifyAccountCapabilityChecked
-          || permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+      if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
+      } else {
+        try {
+          permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+          fillOptions.add(FillOptions.SECONDARY_EMAILS);
+        } catch (AuthException e) {
+          // Do nothing.
+        }
       }
     }
     accountLoader = accountLoaderFactory.create(fillOptions);
diff --git a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
index 6aa88de..f4fa354 100644
--- a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
@@ -57,7 +57,7 @@
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo input)
       throws RestApiException, ConfigInvalidException, RepositoryNotFoundException, IOException,
           PermissionBackendException, OrmException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
index dad6e0f..4e3f1d5 100644
--- a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
@@ -58,7 +58,7 @@
   public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo input)
       throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException, OrmException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/SetPreferences.java b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
index 11ecfdb..fccdabe 100644
--- a/java/com/google/gerrit/server/restapi/account/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
@@ -63,7 +63,7 @@
   public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           OrmException {
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index e804b64..4849698 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -25,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -49,24 +49,20 @@
 
 @Singleton
 public class StarredChanges
-    implements ChildCollection<AccountResource, AccountResource.StarredChange>,
-        AcceptsCreate<AccountResource> {
+    implements ChildCollection<AccountResource, AccountResource.StarredChange> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangesCollection changes;
   private final DynamicMap<RestView<AccountResource.StarredChange>> views;
-  private final Provider<Create> createProvider;
   private final StarredChangesUtil starredChangesUtil;
 
   @Inject
   StarredChanges(
       ChangesCollection changes,
       DynamicMap<RestView<AccountResource.StarredChange>> views,
-      Provider<Create> createProvider,
       StarredChangesUtil starredChangesUtil) {
     this.changes = changes;
     this.views = views;
-    this.createProvider = createProvider;
     this.starredChangesUtil = starredChangesUtil;
   }
 
@@ -93,7 +89,7 @@
     return new RestReadView<AccountResource>() {
       @Override
       public Object apply(AccountResource self)
-          throws BadRequestException, AuthException, OrmException {
+          throws BadRequestException, AuthException, OrmException, PermissionBackendException {
         QueryChanges query = changes.list();
         query.addQuery("starredby:" + self.getUser().getAccountId().get());
         return query.apply(TopLevelResource.INSTANCE);
@@ -101,42 +97,41 @@
     };
   }
 
-  @Override
-  public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
-      throws RestApiException {
-    try {
-      return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
-    } catch (ResourceNotFoundException e) {
-      throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
-    } catch (OrmException | PermissionBackendException | IOException e) {
-      logger.atSevere().withCause(e).log("cannot resolve change");
-      throw new UnprocessableEntityException("internal server error");
-    }
-  }
-
   @Singleton
-  public static class Create implements RestModifyView<AccountResource, EmptyInput> {
+  public static class Create
+      implements RestCollectionCreateView<
+          AccountResource, AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
+    private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
-    private ChangeResource change;
 
     @Inject
-    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+    Create(
+        Provider<CurrentUser> self,
+        ChangesCollection changes,
+        StarredChangesUtil starredChangesUtil) {
       this.self = self;
+      this.changes = changes;
       this.starredChangesUtil = starredChangesUtil;
     }
 
-    public Create setChange(ChangeResource change) {
-      this.change = change;
-      return this;
-    }
-
     @Override
-    public Response<?> apply(AccountResource rsrc, EmptyInput in)
+    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
         throws RestApiException, OrmException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
       }
+
+      ChangeResource change;
+      try {
+        change = changes.parse(TopLevelResource.INSTANCE, id);
+      } catch (ResourceNotFoundException e) {
+        throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
+      } catch (OrmException | PermissionBackendException | IOException e) {
+        logger.atSevere().withCause(e).log("cannot resolve change");
+        throw new UnprocessableEntityException("internal server error");
+      }
+
       try {
         starredChangesUtil.star(
             self.get().getAccountId(),
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index fb809ee..5c4c4d5 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -99,7 +99,7 @@
     @Override
     @SuppressWarnings("unchecked")
     public List<ChangeInfo> apply(AccountResource rsrc)
-        throws BadRequestException, AuthException, OrmException {
+        throws BadRequestException, AuthException, OrmException, PermissionBackendException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to list stars of another account");
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 7978990..c739e54 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -81,7 +81,7 @@
       throws RestApiException, UpdateException, OrmException, PermissionBackendException,
           IOException, ConfigInvalidException {
     // Not allowed to abandon if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().database(dbProvider).check(ChangePermission.ABANDON);
 
@@ -155,7 +155,7 @@
     }
 
     try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
     } catch (OrmException | IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index b7a029b..0b1651d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -19,9 +19,6 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -32,7 +29,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.RestCollectionDeleteMissingView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -59,7 +58,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
@@ -70,32 +68,19 @@
 import org.kohsuke.args4j.Option;
 
 @Singleton
-public class ChangeEdits
-    implements ChildCollection<ChangeResource, ChangeEditResource>,
-        AcceptsCreate<ChangeResource>,
-        AcceptsPost<ChangeResource>,
-        AcceptsDelete<ChangeResource> {
+public class ChangeEdits implements ChildCollection<ChangeResource, ChangeEditResource> {
   private final DynamicMap<RestView<ChangeEditResource>> views;
-  private final Create.Factory createFactory;
-  private final DeleteFile.Factory deleteFileFactory;
   private final Provider<Detail> detail;
   private final ChangeEditUtil editUtil;
-  private final Post post;
 
   @Inject
   ChangeEdits(
       DynamicMap<RestView<ChangeEditResource>> views,
-      Create.Factory createFactory,
       Provider<Detail> detail,
-      ChangeEditUtil editUtil,
-      Post post,
-      DeleteFile.Factory deleteFileFactory) {
+      ChangeEditUtil editUtil) {
     this.views = views;
-    this.createFactory = createFactory;
     this.detail = detail;
     this.editUtil = editUtil;
-    this.post = post;
-    this.deleteFileFactory = deleteFileFactory;
   }
 
   @Override
@@ -118,73 +103,43 @@
     return new ChangeEditResource(rsrc, edit.get(), id.get());
   }
 
-  @Override
-  public Create create(ChangeResource parent, IdString id) throws RestApiException {
-    return createFactory.create(id.get());
-  }
-
-  @Override
-  public Post post(ChangeResource parent) throws RestApiException {
-    return post;
-  }
-
   /**
    * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
    * PUT request with a path was called but change edit wasn't created yet. Change edit is created
    * and PUT handler is called.
    */
-  @Override
-  public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
-    // It's safe to assume that id can never be null, because
-    // otherwise we would end up in dedicated endpoint for
-    // deleting of change edits and not a file in change edit
-    return deleteFileFactory.create(id.get());
-  }
-
-  public static class Create implements RestModifyView<ChangeResource, Put.Input> {
-
-    interface Factory {
-      Create create(String path);
-    }
-
+  public static class Create
+      implements RestCollectionCreateView<ChangeResource, ChangeEditResource, Put.Input> {
     private final Put putEdit;
-    private final String path;
 
     @Inject
-    Create(Put putEdit, @Assisted String path) {
+    Create(Put putEdit) {
       this.putEdit = putEdit;
-      this.path = path;
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, Put.Input input)
+    public Response<?> apply(ChangeResource resource, IdString id, Put.Input input)
         throws AuthException, ResourceConflictException, IOException, OrmException,
             PermissionBackendException {
-      putEdit.apply(resource, path, input.content);
+      putEdit.apply(resource, id.get(), input.content);
       return Response.none();
     }
   }
 
-  public static class DeleteFile implements RestModifyView<ChangeResource, Input> {
-
-    interface Factory {
-      DeleteFile create(String path);
-    }
-
+  public static class DeleteFile
+      implements RestCollectionDeleteMissingView<ChangeResource, ChangeEditResource, Input> {
     private final DeleteContent deleteContent;
-    private final String path;
 
     @Inject
-    DeleteFile(DeleteContent deleteContent, @Assisted String path) {
+    DeleteFile(DeleteContent deleteContent) {
       this.deleteContent = deleteContent;
-      this.path = path;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Input in)
+    public Response<?> apply(ChangeResource rsrc, IdString id, Input in)
         throws IOException, AuthException, ResourceConflictException, OrmException,
             PermissionBackendException {
-      return deleteContent.apply(rsrc, path);
+      return deleteContent.apply(rsrc, id.get());
     }
   }
 
@@ -256,7 +211,8 @@
    * The combination of two operations in one request is supported.
    */
   @Singleton
-  public static class Post implements RestModifyView<ChangeResource, Post.Input> {
+  public static class Post
+      implements RestCollectionModifyView<ChangeResource, ChangeEditResource, Post.Input> {
     public static class Input {
       public String restorePath;
       public String oldPath;
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
index 251c66d..25fc350 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -51,7 +52,7 @@
 
   @Override
   public ChangeMessageResource parse(ChangeResource parent, IdString id)
-      throws OrmException, ResourceNotFoundException {
+      throws OrmException, ResourceNotFoundException, PermissionBackendException {
     String uuid = id.get();
 
     List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index bf8bb1f..e9b4d87 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -43,26 +42,23 @@
 import java.util.List;
 
 @Singleton
-public class ChangesCollection
-    implements RestCollection<TopLevelResource, ChangeResource>, AcceptsPost<TopLevelResource> {
+public class ChangesCollection implements RestCollection<TopLevelResource, ChangeResource> {
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final Provider<QueryChanges> queryFactory;
   private final DynamicMap<RestView<ChangeResource>> views;
   private final ChangeFinder changeFinder;
-  private final CreateChange createChange;
   private final ChangeResource.Factory changeResourceFactory;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
 
   @Inject
-  ChangesCollection(
+  public ChangesCollection(
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       Provider<QueryChanges> queryFactory,
       DynamicMap<RestView<ChangeResource>> views,
       ChangeFinder changeFinder,
-      CreateChange createChange,
       ChangeResource.Factory changeResourceFactory,
       PermissionBackend permissionBackend,
       ProjectCache projectCache) {
@@ -71,7 +67,6 @@
     this.queryFactory = queryFactory;
     this.views = views;
     this.changeFinder = changeFinder;
-    this.createChange = createChange;
     this.changeResourceFactory = changeResourceFactory;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
@@ -130,11 +125,6 @@
     return changeResourceFactory.create(notes, user);
   }
 
-  @Override
-  public CreateChange post(TopLevelResource parent) throws RestApiException {
-    return createChange;
-  }
-
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException, IOException {
     try {
       permissionBackend.currentUser().change(notes).database(db).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4d06c73..7112bbf 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -32,15 +34,14 @@
 import com.google.gerrit.reviewdb.client.FixSuggestion;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
 
-class CommentJson {
+public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
 
@@ -71,7 +72,7 @@
   }
 
   private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
-    public T format(F comment) throws OrmException {
+    public T format(F comment) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
       T info = toInfo(comment, loader);
       if (loader != null) {
@@ -80,7 +81,7 @@
       return info;
     }
 
-    public Map<String, List<T>> format(Iterable<F> comments) throws OrmException {
+    public Map<String, List<T>> format(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
 
       Map<String, List<T>> out = new TreeMap<>();
@@ -96,9 +97,7 @@
         list.add(o);
       }
 
-      for (List<T> list : out.values()) {
-        Collections.sort(list, COMMENT_INFO_ORDER);
-      }
+      out.values().forEach(l -> l.sort(COMMENT_INFO_ORDER));
 
       if (loader != null) {
         loader.fill();
@@ -106,13 +105,14 @@
       return out;
     }
 
-    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+    public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
 
-      List<T> out =
-          FluentIterable.from(comments)
-              .transform(c -> toInfo(c, loader))
-              .toSortedList(COMMENT_INFO_ORDER);
+      ImmutableList<T> out =
+          Streams.stream(comments)
+              .map(c -> toInfo(c, loader))
+              .sorted(COMMENT_INFO_ORDER)
+              .collect(toImmutableList());
 
       if (loader != null) {
         loader.fill();
@@ -161,7 +161,7 @@
     }
   }
 
-  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+  public class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
     @Override
     protected CommentInfo toInfo(Comment c, AccountLoader loader) {
       CommentInfo ci = new CommentInfo();
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 73669a1..b48e040 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyUtil;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -68,7 +69,7 @@
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -97,7 +98,8 @@
 
 @Singleton
 public class CreateChange
-    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
+    extends RetryingRestCollectionModifyView<
+        TopLevelResource, ChangeResource, ChangeInput, Response<ChangeInfo>> {
   private final String anonymousCowardName;
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
@@ -264,6 +266,12 @@
       AccountState accountState = me.state();
       GeneralPreferencesInfo info = accountState.getGeneralPreferences();
 
+      boolean isWorkInProgress =
+          input.workInProgress == null
+              ? rsrc.getProjectState().is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT)
+                  || MoreObjects.firstNonNull(info.workInProgressByDefault, false)
+              : input.workInProgress;
+
       // Add a Change-Id line if there isn't already one
       String commitMessage = subject;
       if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
@@ -307,7 +315,7 @@
       }
       ins.setTopic(topic);
       ins.setPrivate(isPrivate);
-      ins.setWorkInProgress(input.workInProgress != null && input.workInProgress);
+      ins.setWorkInProgress(isWorkInProgress);
       ins.setGroups(groups);
       ins.setNotify(input.notify);
       ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index be689ca..c22fb1e 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -75,7 +76,7 @@
   @Override
   protected Response<CommentInfo> applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index ff85880..a5698b6 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -128,7 +128,7 @@
       throws OrmException, IOException, RestApiException, UpdateException,
           PermissionBackendException {
     // Not allowed to create a new patch set if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
index 942b191..d49a804 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
@@ -18,7 +18,8 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -29,8 +30,8 @@
 import java.util.Optional;
 
 @Singleton
-public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-
+public class DeleteChangeEdit
+    implements RestCollectionModifyView<ChangeResource, ChangeEditResource, Input> {
   private final ChangeEditUtil editUtil;
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
new file mode 100644
index 0000000..67c54c2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2018 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.restapi.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeMessageResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+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;
+import java.util.List;
+
+/** Deletes a change message by rewriting history. */
+@Singleton
+public class DeleteChangeMessage
+    extends RetryingRestModifyView<
+        ChangeMessageResource, DeleteChangeMessageInput, Response<ChangeMessageInfo>> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  public DeleteChangeMessage(
+      Provider<CurrentUser> userProvider,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      ChangeMessagesUtil changeMessagesUtil,
+      AccountLoader.Factory accountLoaderFactory,
+      ChangeNotes.Factory notesFactory,
+      RetryHelper retryHelper) {
+    super(retryHelper);
+    this.userProvider = userProvider;
+    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+    this.changeMessagesUtil = changeMessagesUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public Response<ChangeMessageInfo> applyImpl(
+      BatchUpdate.Factory updateFactory,
+      ChangeMessageResource resource,
+      DeleteChangeMessageInput input)
+      throws RestApiException, PermissionBackendException, OrmException, UpdateException,
+          IOException {
+    CurrentUser user = userProvider.get();
+    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    String newChangeMessage =
+        createNewChangeMessage(user.asIdentifiedUser().getName(), input.reason);
+    DeleteChangeMessageOp deleteChangeMessageOp =
+        new DeleteChangeMessageOp(resource.getChangeMessageIndex(), newChangeMessage);
+    try (BatchUpdate batchUpdate =
+        updateFactory.create(
+            dbProvider.get(), resource.getChangeResource().getProject(), user, TimeUtil.nowTs())) {
+      batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+    }
+
+    ChangeMessageInfo updatedMessageInfo =
+        createUpdatedChangeMessageInfo(resource.getChangeId(), resource.getChangeMessageIndex());
+    return Response.created(updatedMessageInfo);
+  }
+
+  private ChangeMessageInfo createUpdatedChangeMessageInfo(Change.Id id, int targetIdx)
+      throws OrmException, PermissionBackendException {
+    List<ChangeMessage> messages =
+        changeMessagesUtil.byChange(dbProvider.get(), notesFactory.createChecked(id));
+    ChangeMessage updatedChangeMessage = messages.get(targetIdx);
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
+    accountLoader.fill();
+    return info;
+  }
+
+  @VisibleForTesting
+  public static String createNewChangeMessage(String deletedBy, @Nullable String deletedReason) {
+    checkNotNull(deletedBy, "user name must not be null");
+
+    if (Strings.isNullOrEmpty(deletedReason)) {
+      return createNewChangeMessage(deletedBy);
+    }
+    return String.format("Change message removed by: %s\nReason: %s", deletedBy, deletedReason);
+  }
+
+  @VisibleForTesting
+  public static String createNewChangeMessage(String deletedBy) {
+    checkNotNull(deletedBy, "user name must not be null");
+
+    return "Change message removed by: " + deletedBy;
+  }
+
+  private class DeleteChangeMessageOp implements BatchUpdateOp {
+    private final int targetMessageIdx;
+    private final String newMessage;
+
+    DeleteChangeMessageOp(int targetMessageIdx, String newMessage) {
+      this.targetMessageIdx = targetMessageIdx;
+      this.newMessage = newMessage;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+      changeMessagesUtil.replaceChangeMessage(
+          ctx.getDb(), ctx.getUpdate(psId), targetMessageIdx, newMessage);
+      return true;
+    }
+  }
+
+  @Singleton
+  public static class DefaultDeleteChangeMessage
+      extends RetryingRestModifyView<ChangeMessageResource, Input, Response<ChangeMessageInfo>> {
+    private final DeleteChangeMessage deleteChangeMessage;
+
+    @Inject
+    public DefaultDeleteChangeMessage(
+        DeleteChangeMessage deleteChangeMessage, RetryHelper retryHelper) {
+      super(retryHelper);
+      this.deleteChangeMessage = deleteChangeMessage;
+    }
+
+    @Override
+    protected Response<ChangeMessageInfo> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeMessageResource resource, Input input)
+        throws Exception {
+      return deleteChangeMessage.applyImpl(updateFactory, resource, new DeleteChangeMessageInput());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
index 9658fb4..d41e504 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateReviewDb;
@@ -45,6 +46,7 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final ChangeDeleted changeDeleted;
 
   private Change.Id id;
 
@@ -52,10 +54,12 @@
   DeleteChangeOp(
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      ChangeDeleted changeDeleted) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeDeleted = changeDeleted;
   }
 
   @Override
@@ -75,6 +79,7 @@
     deleteChangeElementsFromDb(ctx, id);
 
     ctx.deleteChange();
+    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 4320cd6..a8f39c0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -83,6 +83,10 @@
     CurrentUser user = userProvider.get();
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
 
+    if (input == null) {
+      input = new DeleteCommentInput();
+    }
+
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
index fac1003..3231d16 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
@@ -17,12 +17,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.mail.Address;
 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.ChangeUtil;
 import com.google.gerrit.server.change.NotifyUtil;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
index 91f5d15..2cc4ce4 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
@@ -131,8 +131,7 @@
     currChange = ctx.getChange();
     currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
 
-    LabelTypes labelTypes =
-        projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes(), ctx.getUser());
+    LabelTypes labelTypes = projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes());
     // removing a reviewer will remove all her votes
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index f268a30..dc44e65 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -172,7 +172,7 @@
       ps = psUtil.current(db.get(), ctx.getNotes());
 
       boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
 
       Account.Id accountId = accountState.getAccount().getId();
 
@@ -180,7 +180,6 @@
           approvalsUtil.byPatchSetUser(
               ctx.getDb(),
               ctx.getNotes(),
-              ctx.getUser(),
               psId,
               accountId,
               ctx.getRevWalk(),
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
index f78fae2..e95f8d8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -35,7 +36,8 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
+  public Response<AccountInfo> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
     Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
     if (assignee.isPresent()) {
       return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index b8db6a5..d067dff 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -33,7 +34,7 @@
   }
 
   @Override
-  public CommentInfo apply(CommentResource rsrc) throws OrmException {
+  public CommentInfo apply(CommentResource rsrc) throws OrmException, PermissionBackendException {
     return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 489c7cb..1fae739 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
@@ -440,9 +441,9 @@
         } catch (NumberFormatException e) {
           throw new CmdLineException(
               owner,
-              String.format(
-                  "\"%s\" is not a valid value for \"%s\"",
-                  value, ((NamedOptionDef) option).name()));
+              localizable("\"%s\" is not a valid value for \"%s\""),
+              value,
+              ((NamedOptionDef) option).name());
         }
       }
       setter.addValue(context);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index 787c93e..6049607 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -33,7 +34,8 @@
   }
 
   @Override
-  public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
+  public CommentInfo apply(DraftCommentResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
index 354558b..279cfe3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,7 +40,8 @@
   }
 
   @Override
-  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
+  public Response<List<AccountInfo>> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
 
     Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
     if (pastAssignees == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
index 42675f6..75019af 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
@@ -30,13 +30,15 @@
 public class GetPureRevert implements RestReadView<ChangeResource> {
 
   private final PureRevert pureRevert;
+  @Nullable private String claimedOriginal;
 
   @Option(
       name = "--claimed-original",
       aliases = {"-o"},
       usage = "SHA1 (40 digit hex) of the original commit")
-  @Nullable
-  private String claimedOriginal;
+  public void setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+  }
 
   @Inject
   GetPureRevert(PureRevert pureRevert) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
index bd1f66a..0197068 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -33,7 +34,8 @@
   }
 
   @Override
-  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
+  public RobotCommentInfo apply(RobotCommentResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index 37dc207..40f4642 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -49,7 +50,7 @@
 
   @Override
   public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     return commentJson
         .get()
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index d7a102a..a524f6d 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,7 +51,7 @@
 
   @Override
   public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, PermissionBackendException {
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index cf76ef1..39c12f7 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,7 +48,8 @@
   }
 
   @Override
-  public List<ChangeMessageInfo> apply(ChangeResource resource) throws OrmException {
+  public List<ChangeMessageInfo> apply(ChangeResource resource)
+      throws OrmException, PermissionBackendException {
     List<ChangeMessage> messages =
         changeMessagesUtil.byChange(dbProvider.get(), resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index dd8de6f..dc92ced 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -47,7 +48,7 @@
 
   @Override
   public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     return commentJson
         .get()
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 750e74f..46bb33f 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -16,12 +16,12 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index b7dc553..dbd0ccf 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -51,7 +53,8 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
@@ -59,7 +62,8 @@
         .format(listComments(rsrc));
   }
 
-  public List<CommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+  public ImmutableList<CommentInfo> getComments(RevisionResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index d0630b7..32c4ea3 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -17,12 +17,12 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 61219d3..99366aa 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -42,7 +44,8 @@
   }
 
   @Override
-  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(true)
@@ -50,7 +53,8 @@
         .format(listComments(rsrc));
   }
 
-  public List<RobotCommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+  public ImmutableList<RobotCommentInfo> getComments(RevisionResource rsrc)
+      throws OrmException, PermissionBackendException {
     return commentJson
         .get()
         .setFillAccounts(true)
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 7955fa59..5a5d2bb 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -68,6 +68,7 @@
     DynamicMap.mapOf(binder(), VOTE_KIND);
     DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
 
+    postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
     post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
@@ -108,9 +109,9 @@
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
 
-    post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
+    postOnCollection(REVIEWER_KIND).to(PostReviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
     post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
@@ -165,9 +166,12 @@
     get(FILE_KIND, "blame").to(GetBlame.class);
 
     child(CHANGE_KIND, "edit").to(ChangeEdits.class);
-    delete(CHANGE_KIND, "edit").to(DeleteChangeEdit.class);
-    child(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
-    child(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
+    create(CHANGE_EDIT_KIND).to(ChangeEdits.Create.class);
+    deleteMissing(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteFile.class);
+    postOnCollection(CHANGE_EDIT_KIND).to(ChangeEdits.Post.class);
+    deleteOnCollection(CHANGE_EDIT_KIND).to(DeleteChangeEdit.class);
+    post(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
+    post(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
     put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
     get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
     put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
@@ -178,10 +182,10 @@
 
     child(CHANGE_KIND, "messages").to(ChangeMessages.class);
     get(CHANGE_MESSAGE_KIND).to(GetChangeMessage.class);
+    delete(CHANGE_MESSAGE_KIND).to(DeleteChangeMessage.DefaultDeleteChangeMessage.class);
+    post(CHANGE_MESSAGE_KIND, "delete").to(DeleteChangeMessage.class);
 
     factory(AccountLoader.Factory.class);
-    factory(ChangeEdits.Create.Factory.class);
-    factory(ChangeEdits.DeleteFile.Factory.class);
     factory(ChangeInserter.Factory.class);
     factory(ChangeResource.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 5fcf967..3833050 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -21,11 +21,13 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -42,7 +44,6 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
@@ -86,7 +87,6 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ProjectCache projectCache;
-  private final Provider<CurrentUser> userProvider;
 
   @Inject
   Move(
@@ -99,8 +99,7 @@
       RetryHelper retryHelper,
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
-      ProjectCache projectCache,
-      Provider<CurrentUser> userProvider) {
+      ProjectCache projectCache) {
     super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
@@ -111,7 +110,6 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.projectCache = projectCache;
-    this.userProvider = userProvider;
   }
 
   @Override
@@ -122,6 +120,9 @@
     Change change = rsrc.getChange();
     Project.NameKey project = rsrc.getProject();
     IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
+    if (input.destinationBranch == null) {
+      throw new BadRequestException("destination branch is required");
+    }
     input.destinationBranch = RefNames.fullName(input.destinationBranch);
 
     if (change.getStatus().isClosed()) {
@@ -134,7 +135,7 @@
     }
 
     // Not allowed to move if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     // Move requires abandoning this change, and creating a new change.
     try {
@@ -145,12 +146,13 @@
     }
     projectCache.checkedGet(project).checkStatePermitsWrite();
 
+    Op op = new Op(input);
     try (BatchUpdate u =
         updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
-      u.addOp(change.getId(), new Op(input));
+      u.addOp(change.getId(), op);
       u.execute();
     }
-    return json.noOptions().format(project, rsrc.getId());
+    return json.noOptions().format(op.getChange());
   }
 
   private class Op implements BatchUpdateOp {
@@ -163,6 +165,11 @@
       this.input = input;
     }
 
+    @Nullable
+    public Change getChange() {
+      return change;
+    }
+
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws OrmException, ResourceConflictException, IOException {
@@ -250,19 +257,13 @@
       List<PatchSetApproval> approvals = new ArrayList<>();
       for (PatchSetApproval psa :
           approvalsUtil.byPatchSet(
-              ctx.getDb(),
-              ctx.getNotes(),
-              userProvider.get(),
-              psId,
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+              ctx.getDb(), ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.checkedGet(project);
-        LabelType type =
-            projectState.getLabelTypes(ctx.getNotes(), ctx.getUser()).byLabel(psa.getLabelId());
+        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.getLabelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
-        if (type.isMaxNegative(psa) && type.getFunction().isBlock()) {
+        if (type == null || (type.isMaxNegative(psa) && type.getFunction().isBlock())) {
           continue;
         }
 
@@ -303,7 +304,7 @@
     }
 
     try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
     } catch (OrmException | IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 489eaeb..9bbaf69 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
@@ -64,6 +65,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -94,7 +96,6 @@
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -145,6 +146,8 @@
 @Singleton
 public class PostReview
     extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS =
       "only change owner can specify work_in_progress or ready";
@@ -174,6 +177,7 @@
   private final Config gerritConfig;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
   private final boolean strictLabels;
 
   @Inject
@@ -196,7 +200,8 @@
       NotifyUtil notifyUtil,
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend) {
     super(retryHelper);
     this.db = db;
     this.changeResourceFactory = changeResourceFactory;
@@ -216,6 +221,7 @@
     this.gerritConfig = gerritConfig;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
@@ -237,7 +243,7 @@
       throw new ResourceConflictException("cannot post review on edit");
     }
     ProjectState projectState = projectCache.checkedGet(revision.getProject());
-    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes(), revision.getUser());
+    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, labelTypes, input);
@@ -482,7 +488,11 @@
 
     IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
     try {
-      perm.user(reviewer).check(ChangePermission.READ);
+      permissionBackend
+          .user(reviewer)
+          .database(db)
+          .change(rev.getNotes())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new UnprocessableEntityException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
@@ -572,7 +582,7 @@
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
+      PatchSet.Id patchSetId = revision.getPatchSet().getId();
       ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
 
       List<T> comments = entry.getValue();
@@ -1148,7 +1158,7 @@
       List<PatchSetApproval> del = new ArrayList<>();
       List<PatchSetApproval> ups = new ArrayList<>();
       Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
       Map<String, Short> allApprovals =
           getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
       Map<String, Short> previous =
@@ -1209,8 +1219,15 @@
       }
 
       forceCallerAsReviewer(projectState, ctx, current, ups, del);
-      ctx.getDb().patchSetApprovals().delete(del);
-      ctx.getDb().patchSetApprovals().upsert(ups);
+
+      if (!del.isEmpty()) {
+        ctx.getDb().patchSetApprovals().delete(del);
+      }
+
+      if (!ups.isEmpty()) {
+        ctx.getDb().patchSetApprovals().upsert(ups);
+      }
+
       return !del.isEmpty() || !ups.isEmpty();
     }
 
@@ -1296,12 +1313,15 @@
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          LabelId labelId =
-              projectState
-                  .getLabelTypes(ctx.getNotes(), ctx.getUser())
-                  .getLabelTypes()
-                  .get(0)
-                  .getLabelId();
+          List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
+          if (labelTypes.isEmpty()) {
+            logger.atWarning().log(
+                "no label type found for project %s, change %s",
+                projectState.getName(), ctx.getChange().getChangeId());
+            return;
+          }
+
+          LabelId labelId = labelTypes.get(0).getLabelId();
           PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
@@ -1322,14 +1342,13 @@
     private Map<String, PatchSetApproval> scanLabels(
         ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
         throws OrmException, IOException {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
       Map<String, PatchSetApproval> current = new HashMap<>();
 
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getDb(),
               ctx.getNotes(),
-              ctx.getUser(),
               psId,
               user.getAccountId(),
               ctx.getRevWalk(),
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 65c7db7..0244775 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -37,9 +37,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -51,10 +53,10 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -69,7 +71,7 @@
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -85,7 +87,8 @@
 
 @Singleton
 public class PostReviewers
-    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
+    extends RetryingRestCollectionModifyView<
+        ChangeResource, ReviewerResource, AddReviewerInput, AddReviewerResult> {
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
@@ -248,9 +251,7 @@
       return null;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.absentUser(reviewerUser.getAccountId()).ref(rsrc.getChange().getDest());
-    if (isValidReviewer(reviewerUser.getAccount(), perm)) {
+    if (isValidReviewer(rsrc.getChange().getDest(), reviewerUser.getAccount())) {
       return new Addition(
           reviewer,
           rsrc,
@@ -331,10 +332,8 @@
               ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
     for (Account member : members) {
-      if (isValidReviewer(member, perm)) {
+      if (isValidReviewer(rsrc.getChange().getDest(), member)) {
         reviewers.add(member.getId());
       }
     }
@@ -350,14 +349,17 @@
       NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws PermissionBackendException {
-    if (!permissionBackend
-        .user(anonymousProvider.get())
-        .change(rsrc.getNotes())
-        .database(dbProvider)
-        .test(ChangePermission.READ)) {
+    try {
+      permissionBackend
+          .user(anonymousProvider.get())
+          .change(rsrc.getNotes())
+          .database(dbProvider)
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
       return fail(
           reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
     }
+
     if (!migration.readChanges()) {
       // addByEmail depends on NoteDb.
       return fail(
@@ -371,7 +373,7 @@
         reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
   }
 
-  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
+  private boolean isValidReviewer(Branch.NameKey branch, Account member)
       throws PermissionBackendException {
     if (!member.isActive()) {
       return false;
@@ -380,7 +382,11 @@
     // Does not account for draft status as a user might want to let a
     // reviewer see a draft.
     try {
-      perm.absentUser(member.getId()).check(RefPermission.READ);
+      permissionBackend
+          .absentUser(member.getId())
+          .database(dbProvider)
+          .ref(branch)
+          .check(RefPermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
@@ -452,17 +458,13 @@
       }
 
       ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
-      PermissionBackend.ForChange perm =
-          permissionBackend.user(caller).database(dbProvider).change(cd);
-
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
       PostReviewersOp.Result opResult = op.getResult();
       if (migration.readChanges() && state == CC) {
         result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
         for (Account.Id accountId : opResult.addedCCs()) {
-          result.ccs.add(
-              json.format(new ReviewerInfo(accountId.get()), perm.absentUser(accountId), cd));
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
         for (Address a : reviewersByEmail) {
@@ -475,7 +477,7 @@
           result.reviewers.add(
               json.format(
                   new ReviewerInfo(psa.getAccountId().get()),
-                  perm.absentUser(psa.getAccountId()),
+                  psa.getAccountId(),
                   cd,
                   ImmutableList.of(psa)));
         }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
index 0502e91..08b66aa 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -42,7 +43,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -169,7 +169,7 @@
                 ctx.getUpdate(ctx.getChange().currentPatchSetId()),
                 projectCache
                     .checkedGet(rsrc.getProject())
-                    .getLabelTypes(rsrc.getChange().getDest(), ctx.getUser()),
+                    .getLabelTypes(rsrc.getChange().getDest()),
                 rsrc.getChange(),
                 reviewers);
         if (addedReviewers.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
index b356f18..3d401c4 100644
--- a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -15,16 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 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.RestView;
-import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
@@ -44,77 +37,44 @@
 
 @Singleton
 public class PublishChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Publish publish;
+    extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
+  private final ChangeEditUtil editUtil;
+  private final NotifyUtil notifyUtil;
+  private final ContributorAgreementsChecker contributorAgreementsChecker;
 
   @Inject
-  PublishChangeEdit(Publish publish) {
-    this.publish = publish;
+  PublishChangeEdit(
+      RetryHelper retryHelper,
+      ChangeEditUtil editUtil,
+      NotifyUtil notifyUtil,
+      ContributorAgreementsChecker contributorAgreementsChecker) {
+    super(retryHelper);
+    this.editUtil = editUtil;
+    this.notifyUtil = notifyUtil;
+    this.contributorAgreementsChecker = contributorAgreementsChecker;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Publish post(ChangeResource parent) throws RestApiException {
-    return publish;
-  }
-
-  @Singleton
-  public static class Publish
-      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
-
-    private final ChangeEditUtil editUtil;
-    private final NotifyUtil notifyUtil;
-    private final ContributorAgreementsChecker contributorAgreementsChecker;
-
-    @Inject
-    Publish(
-        RetryHelper retryHelper,
-        ChangeEditUtil editUtil,
-        NotifyUtil notifyUtil,
-        ContributorAgreementsChecker contributorAgreementsChecker) {
-      super(retryHelper);
-      this.editUtil = editUtil;
-      this.notifyUtil = notifyUtil;
-      this.contributorAgreementsChecker = contributorAgreementsChecker;
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
+      throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
+          NoSuchProjectException {
+    contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+    if (!edit.isPresent()) {
+      throw new ResourceConflictException(
+          String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
     }
-
-    @Override
-    protected Response<?> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
-        throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
-            NoSuchProjectException {
-      contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-      if (!edit.isPresent()) {
-        throw new ResourceConflictException(
-            String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
-      }
-      if (in == null) {
-        in = new PublishChangeEditInput();
-      }
-      editUtil.publish(
-          updateFactory,
-          rsrc.getNotes(),
-          rsrc.getUser(),
-          edit.get(),
-          in.notify,
-          notifyUtil.resolveAccounts(in.notifyDetails));
-      return Response.none();
+    if (in == null) {
+      in = new PublishChangeEditInput();
     }
+    editUtil.publish(
+        updateFactory,
+        rsrc.getNotes(),
+        rsrc.getUser(),
+        edit.get(),
+        in.notify,
+        notifyUtil.resolveAccounts(in.notifyDetails));
+    return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index b6fc010..21bd3ce 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.change.PostReviewers.Addition;
@@ -55,6 +56,7 @@
   private final Provider<ReviewDb> db;
   private final PostReviewers postReviewers;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   PutAssignee(
@@ -63,13 +65,15 @@
       RetryHelper retryHelper,
       Provider<ReviewDb> db,
       PostReviewers postReviewers,
-      AccountLoader.Factory accountLoaderFactory) {
+      AccountLoader.Factory accountLoaderFactory,
+      PermissionBackend permissionBackend) {
     super(retryHelper);
     this.accounts = accounts;
     this.assigneeFactory = assigneeFactory;
     this.db = db;
     this.postReviewers = postReviewers;
     this.accountLoaderFactory = accountLoaderFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -89,7 +93,11 @@
       throw new UnprocessableEntityException(input.assignee + " is not active");
     }
     try {
-      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+      permissionBackend
+          .absentUser(assignee.getAccountId())
+          .database(db)
+          .change(rsrc.getNotes())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new AuthException("read not permitted for " + input.assignee);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index e6ede34..76ef106 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -80,7 +81,7 @@
   @Override
   protected Response<CommentInfo> applyImpl(
       BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
       return delete.applyImpl(updateFactory, rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index eb46521..bee0ed7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -184,7 +184,7 @@
     }
 
     // Not allowed to put message if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(changeNotes, userProvider.get());
+    psUtil.checkPatchSetNotLocked(changeNotes);
     try {
       permissionBackend
           .user(userProvider.get())
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 4685905..45a837a 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -75,7 +75,12 @@
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
-    Op op = new Op(input != null ? input : new TopicInput());
+    TopicInput sanitizedInput = input == null ? new TopicInput() : input;
+    if (sanitizedInput.topic != null) {
+      sanitizedInput.topic = sanitizedInput.topic.trim();
+    }
+
+    Op op = new Op(sanitizedInput);
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 1c9e420..b53c4e6 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
@@ -104,7 +105,7 @@
 
   @Override
   public List<?> apply(TopLevelResource rsrc)
-      throws BadRequestException, AuthException, OrmException {
+      throws BadRequestException, AuthException, OrmException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
       out = query();
@@ -117,7 +118,8 @@
     return out.size() == 1 ? out.get(0) : out;
   }
 
-  private List<List<ChangeInfo>> query() throws OrmException, QueryParseException {
+  private List<List<ChangeInfo>> query()
+      throws OrmException, QueryParseException, PermissionBackendException {
     if (imp.isDisabled()) {
       throw new QueryParseException("query disabled");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 99a755ae..0fffb58 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -107,7 +107,7 @@
       throws OrmException, UpdateException, RestApiException, IOException,
           PermissionBackendException {
     // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
@@ -228,7 +228,7 @@
     }
 
     try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
     } catch (OrmException | IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 7e1bb4d..6020e95 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -15,24 +15,18 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 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.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -40,59 +34,30 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Rebase rebase;
+public class RebaseChangeEdit extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
+  private final GitRepositoryManager repositoryManager;
+  private final ChangeEditModifier editModifier;
 
   @Inject
-  RebaseChangeEdit(Rebase rebase) {
-    this.rebase = rebase;
+  RebaseChangeEdit(
+      RetryHelper retryHelper,
+      GitRepositoryManager repositoryManager,
+      ChangeEditModifier editModifier) {
+    super(retryHelper);
+    this.repositoryManager = repositoryManager;
+    this.editModifier = editModifier;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Rebase post(ChangeResource parent) throws RestApiException {
-    return rebase;
-  }
-
-  @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Input> {
-
-    private final GitRepositoryManager repositoryManager;
-    private final ChangeEditModifier editModifier;
-
-    @Inject
-    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
-      this.repositoryManager = repositoryManager;
-      this.editModifier = editModifier;
+  protected Response<?> applyImpl(BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
+      throws AuthException, ResourceConflictException, IOException, OrmException,
+          PermissionBackendException {
+    Project.NameKey project = rsrc.getProject();
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      editModifier.rebaseEdit(repository, rsrc.getNotes());
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
-
-    @Override
-    public Response<?> apply(ChangeResource rsrc, Input in)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      Project.NameKey project = rsrc.getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.rebaseEdit(repository, rsrc.getNotes());
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
+    return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebuild.java b/java/com/google/gerrit/server/restapi/change/Rebuild.java
index 4508a99..dc390cc 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebuild.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebuild.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -67,7 +68,8 @@
 
   @Override
   public BinaryResult apply(ChangeResource rsrc, Input input)
-      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException,
+          ResourceConflictException {
     if (!migration.commitChangeWrites()) {
       throw new ResourceNotFoundException();
     }
@@ -82,6 +84,9 @@
     // in the case of races. This should be easy enough to detect by rerunning.
     ChangeBundle reviewDbBundle =
         bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
+    if (reviewDbBundle == null) {
+      throw new ResourceConflictException("change is missing in ReviewDb");
+    }
     rebuild(rsrc);
     ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
     ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 5e4ede3..a29347f 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -91,7 +91,7 @@
       throws RestApiException, UpdateException, OrmException, PermissionBackendException,
           IOException {
     // Not allowed to restore if the current patch set is locked.
-    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().database(dbProvider).check(ChangePermission.RESTORE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
@@ -183,7 +183,7 @@
     }
 
     try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
     } catch (OrmException | IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 55d0933..e008681 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -101,7 +101,7 @@
   private final PatchSetUtil psUtil;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeJson.Factory json;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeReverted changeReverted;
   private final ContributorAgreementsChecker contributorAgreements;
@@ -120,7 +120,7 @@
       PatchSetUtil psUtil,
       RevertedSender.Factory revertedSenderFactory,
       ChangeJson.Factory json,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       ApprovalsUtil approvalsUtil,
       ChangeReverted changeReverted,
       ContributorAgreementsChecker contributorAgreements,
@@ -185,7 +185,7 @@
       }
 
       Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = new PersonIdent(serverIdent, now);
+      PersonIdent committerIdent = serverIdent.get();
       PersonIdent authorIdent =
           user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
index cfd20c2..29c5649 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -80,10 +81,7 @@
       ReviewerInfo info =
           format(
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              permissionBackend
-                  .absentUser(rsrc.getReviewerUser().getAccountId())
-                  .database(db)
-                  .change(cd),
+              rsrc.getReviewerUser().getAccountId(),
               cd);
       loader.put(info);
       infos.add(info);
@@ -97,22 +95,19 @@
     return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
-  public ReviewerInfo format(ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+  public ReviewerInfo format(ReviewerInfo out, Account.Id reviewer, ChangeData cd)
       throws OrmException, PermissionBackendException {
     PatchSet.Id psId = cd.change().currentPatchSetId();
     return format(
         out,
-        perm,
+        reviewer,
         cd,
         approvalsUtil.byPatchSetUser(
-            db.get(), cd.notes(), perm.user(), psId, new Account.Id(out._accountId), null, null));
+            db.get(), cd.notes(), psId, new Account.Id(out._accountId), null, null));
   }
 
   public ReviewerInfo format(
-      ReviewerInfo out,
-      PermissionBackend.ForChange perm,
-      ChangeData cd,
-      Iterable<PatchSetApproval> approvals)
+      ReviewerInfo out, Account.Id reviewer, ChangeData cd, Iterable<PatchSetApproval> approvals)
       throws OrmException, PermissionBackendException {
     LabelTypes labelTypes = cd.getLabelTypes();
 
@@ -128,6 +123,9 @@
     // do not exist in the DB.
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
+      PermissionBackend.ForChange perm =
+          permissionBackend.absentUser(reviewer).database(db).change(cd);
+
       for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
         if (rec.labels == null) {
           continue;
@@ -135,10 +133,15 @@
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
           LabelType type = labelTypes.byLabel(name);
-          if (!out.approvals.containsKey(name)
-              && type != null
-              && perm.test(new LabelPermission(type))) {
+          if (out.approvals.containsKey(name) || type == null) {
+            continue;
+          }
+
+          try {
+            perm.check(new LabelPermission(type));
             out.approvals.put(name, formatValue((short) 0));
+          } catch (AuthException e) {
+            // Do nothing.
           }
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 47c6970..6b7a708 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -107,8 +107,13 @@
       ProjectState projectState,
       List<Account.Id> candidateList)
       throws OrmException, IOException, ConfigInvalidException {
+    logger.atFine().log("Candidates %s", candidateList);
+
     String query = suggestReviewers.getQuery();
+    logger.atFine().log("query: %s", query);
+
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+    logger.atFine().log("base weight: %s", baseWeight);
 
     Map<Account.Id, MutableDouble> reviewerScores;
     if (Strings.isNullOrEmpty(query)) {
@@ -116,6 +121,7 @@
     } else {
       reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
     }
+    logger.atFine().log("Base reviewer scores: %s", reviewerScores);
 
     // Send the query along with a candidate list to all plugins and merge the
     // results. Plugins don't necessarily need to use the candidates list, they
@@ -163,6 +169,7 @@
           }
         }
       }
+      logger.atFine().log("Reviewer scores: %s", reviewerScores);
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
       return ImmutableList.of();
@@ -170,12 +177,20 @@
 
     if (changeNotes != null) {
       // Remove change owner
-      reviewerScores.remove(changeNotes.getChange().getOwner());
+      if (reviewerScores.remove(changeNotes.getChange().getOwner()) != null) {
+        logger.atFine().log("Remove change owner %s", changeNotes.getChange().getOwner());
+      }
 
       // Remove existing reviewers
-      reviewerScores
-          .keySet()
-          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
+      approvalsUtil
+          .getReviewers(dbProvider.get(), changeNotes)
+          .byState(REVIEWER)
+          .forEach(
+              r -> {
+                if (reviewerScores.remove(r) != null) {
+                  logger.atFine().log("Remove existing reviewer %s", r);
+                }
+              });
     }
 
     // Sort results
@@ -185,6 +200,7 @@
             .stream()
             .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
+    logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index a4cfbd2..f0aef13 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 65052a5..3becf24 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -43,13 +44,12 @@
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
@@ -129,7 +129,6 @@
   private final IndexConfig indexConfig;
   private final AccountControl.Factory accountControlFactory;
   private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
 
   @Inject
   ReviewersUtil(
@@ -142,8 +141,7 @@
       AccountIndexCollection accountIndexes,
       IndexConfig indexConfig,
       AccountControl.Factory accountControlFactory,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
+      Provider<CurrentUser> self) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.groupBackend = groupBackend;
@@ -154,7 +152,6 @@
     this.indexConfig = indexConfig;
     this.accountControlFactory = accountControlFactory;
     this.self = self;
-    this.permissionBackend = permissionBackend;
   }
 
   public interface VisibilityControl {
@@ -232,24 +229,30 @@
         // For performance reasons we don't use AccountQueryProvider as it would always load the
         // complete account from the cache (or worse, from NoteDb) even though we only need the ID
         // which we can directly get from the returned results.
+        Predicate<AccountState> pred =
+            Predicate.and(
+                AccountPredicates.isActive(),
+                accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+        logger.atFine().log("accounts index query: %s", pred);
         ResultSet<FieldBundle> result =
             accountIndexes
                 .getSearchIndex()
                 .getSource(
-                    Predicate.and(
-                        AccountPredicates.isActive(),
-                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())),
+                    pred,
                     QueryOptions.create(
                         indexConfig,
                         0,
                         suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
                         ImmutableSet.of(AccountField.ID.getName())))
                 .readRaw();
-        return result
-            .toList()
-            .stream()
-            .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
-            .collect(toList());
+        List<Account.Id> matches =
+            result
+                .toList()
+                .stream()
+                .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+                .collect(toList());
+        logger.atFine().log("Matches: %s", matches);
+        return matches;
       } catch (QueryParseException e) {
         return ImmutableList.of();
       }
@@ -299,12 +302,9 @@
   }
 
   private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
-      throws OrmException, PermissionBackendException {
+      throws PermissionBackendException {
     Set<FillOptions> fillOptions =
-        permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)
-            ? EnumSet.of(FillOptions.SECONDARY_EMAILS)
-            : EnumSet.noneOf(FillOptions.class);
-    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+        Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS));
     AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);
 
     try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
@@ -381,8 +381,10 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
+    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
     if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+      logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
 
@@ -390,6 +392,7 @@
       Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
+        logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
         return result;
       }
 
@@ -399,6 +402,11 @@
       }
 
       boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+      if (needsConfirmation) {
+        logger.atFine().log(
+            "group %s needs confirmation to be added as reviewer, it has %d members",
+            group.getUUID(), result.size);
+      }
 
       // require that at least one member in the group can see the change
       for (Account account : members) {
@@ -408,9 +416,12 @@
           } else {
             result.allowed = true;
           }
+          logger.atFine().log("Suggest group %s", group.getUUID());
           return result;
         }
       }
+      logger.atFine().log(
+          "Ignore group %s since none of its members can see the change", group.getUUID());
     } catch (NoSuchProjectException e) {
       return result;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 7cf30e2..b9b7a4f 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -22,12 +22,12 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
index 1b50834..8aac92c 100644
--- a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
@@ -18,8 +18,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.PrivateStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -44,19 +47,23 @@
   }
 
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final boolean isPrivate;
   private final Input input;
   private final PrivateStateChanged privateStateChanged;
 
   private Change change;
+  private PatchSet ps;
 
   @Inject
   SetPrivateOp(
       PrivateStateChanged privateStateChanged,
+      PatchSetUtil psUtil,
       @Assisted ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
       @Assisted Input input) {
     this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.isPrivate = isPrivate;
     this.input = input;
     this.privateStateChanged = privateStateChanged;
@@ -65,6 +72,8 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
     change = ctx.getChange();
+    ChangeNotes notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), notes, change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
@@ -75,7 +84,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
   }
 
   private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 5298857..faf946f 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
@@ -64,12 +65,28 @@
   protected Response<?> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
-    Change change = rsrc.getChange();
-    if (!rsrc.isUserOwner()
-        && !permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      throw new AuthException("not allowed to set ready for review");
+    if (!rsrc.isUserOwner()) {
+      boolean hasAdministrateServerPermission = false;
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+        hasAdministrateServerPermission = true;
+      } catch (AuthException e) {
+        // Skip.
+      }
+
+      if (!hasAdministrateServerPermission) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(rsrc.getProject())
+              .check(ProjectPermission.WRITE_CONFIG);
+        } catch (AuthException exp) {
+          throw new AuthException("not allowed to set ready for review");
+        }
+      }
     }
 
+    Change change = rsrc.getChange();
     if (change.getStatus() != Status.NEW) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
@@ -96,8 +113,13 @@
                 rsrc.getChange().getStatus() == Status.NEW && rsrc.getChange().isWorkInProgress(),
                 or(
                     rsrc.isUserOwner(),
-                    permissionBackend
-                        .currentUser()
-                        .testCond(GlobalPermission.ADMINISTRATE_SERVER))));
+                    or(
+                        permissionBackend
+                            .currentUser()
+                            .testCond(GlobalPermission.ADMINISTRATE_SERVER),
+                        permissionBackend
+                            .currentUser()
+                            .project(rsrc.getProject())
+                            .testCond(ProjectPermission.WRITE_CONFIG)))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 93568d5..1f9d81f 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
@@ -64,13 +65,28 @@
   protected Response<?> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
-    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      boolean hasAdministrateServerPermission = false;
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+        hasAdministrateServerPermission = true;
+      } catch (AuthException e) {
+        // Skip.
+      }
 
-    if (!rsrc.isUserOwner()
-        && !permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      throw new AuthException("not allowed to set work in progress");
+      if (!hasAdministrateServerPermission) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(rsrc.getProject())
+              .check(ProjectPermission.WRITE_CONFIG);
+        } catch (AuthException exp) {
+          throw new AuthException("not allowed to set work in progress");
+        }
+      }
     }
 
+    Change change = rsrc.getChange();
     if (change.getStatus() != Status.NEW) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
@@ -97,8 +113,13 @@
                 rsrc.getChange().getStatus() == Status.NEW && !rsrc.getChange().isWorkInProgress(),
                 or(
                     rsrc.isUserOwner(),
-                    permissionBackend
-                        .currentUser()
-                        .testCond(GlobalPermission.ADMINISTRATE_SERVER))));
+                    or(
+                        permissionBackend
+                            .currentUser()
+                            .testCond(GlobalPermission.ADMINISTRATE_SERVER),
+                        permissionBackend
+                            .currentUser()
+                            .project(rsrc.getProject())
+                            .testCond(ProjectPermission.WRITE_CONFIG)))));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index a161767..f9a5087 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -468,7 +468,11 @@
     CurrentUser caller = rsrc.getUser();
     IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
     try {
-      perm.user(submitter).check(ChangePermission.READ);
+      permissionBackend
+          .user(submitter)
+          .database(dbProvider)
+          .change(rsrc.getNotes())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new UnprocessableEntityException(
           String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index bc3dfa7..0ce5750 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -56,14 +54,13 @@
   @Inject
   SuggestChangeReviewers(
       AccountVisibility av,
-      GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil,
       ProjectCache projectCache) {
-    super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    super(av, dbProvider, cfg, reviewersUtil);
     this.permissionBackend = permissionBackend;
     this.self = self;
     this.projectCache = projectCache;
@@ -85,14 +82,17 @@
   }
 
   private VisibilityControl getVisibility(ChangeResource rsrc) {
-    // Use the destination reference, not the change, as private changes deny anyone who is not
-    // already a reviewer.
-    PermissionBackend.ForRef perm = permissionBackend.currentUser().ref(rsrc.getChange().getDest());
+
     return new VisibilityControl() {
       @Override
-      public boolean isVisibleTo(Account.Id account) throws OrmException {
-        IdentifiedUser who = identifiedUserFactory.create(account);
-        return perm.user(who).testOrFalse(RefPermission.READ);
+      public boolean isVisibleTo(Account.Id account) {
+        // Use the destination reference, not the change, as private changes deny anyone who is not
+        // already a reviewer.
+        return permissionBackend
+            .absentUser(account)
+            .database(dbProvider)
+            .ref(rsrc.getChange().getDest())
+            .testOrFalse(RefPermission.READ);
       }
     };
   }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index ac8e81c..b1d49f2 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -16,9 +16,9 @@
 
 import static com.google.gerrit.server.config.GerritConfigListenerHelper.acceptIfChanged;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -28,10 +28,11 @@
 import org.kohsuke.args4j.Option;
 
 public class SuggestReviewers {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
   protected final Provider<ReviewDb> dbProvider;
-  protected final IdentifiedUser.GenericFactory identifiedUserFactory;
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
@@ -82,12 +83,10 @@
   @Inject
   public SuggestReviewers(
       AccountVisibility av,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil) {
     this.dbProvider = dbProvider;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.reviewersUtil = reviewersUtil;
     this.maxSuggestedReviewers =
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
@@ -105,6 +104,8 @@
             "addreviewer",
             "maxWithoutConfirmation",
             PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+
+    logger.atFine().log("AccountVisibility: %s", av.name());
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 2a18612..cdd7426 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -63,7 +64,7 @@
 
   @Override
   public List<Record> apply(RevisionResource rsrc, TestSubmitRuleInput input)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, PermissionBackendException {
     if (input == null) {
       input = new TestSubmitRuleInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index b931c7e..3b2548c 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -87,7 +87,6 @@
           approvalsUtil.byPatchSetUser(
               db.get(),
               rsrc.getChangeResource().getNotes(),
-              rsrc.getChangeResource().getUser(),
               rsrc.getChange().currentPatchSetId(),
               rsrc.getReviewerUser().getAccountId(),
               null,
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
index 548bc03..02e5f68 100644
--- a/java/com/google/gerrit/server/restapi/config/AgreementJson.java
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.GroupJson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -48,7 +49,7 @@
     this.groupJson = groupJson;
   }
 
-  public AgreementInfo format(ContributorAgreement ca) {
+  public AgreementInfo format(ContributorAgreement ca) throws PermissionBackendException {
     AgreementInfo info = new AgreementInfo();
     info.name = ca.getName();
     info.description = ca.getDescription();
diff --git a/java/com/google/gerrit/server/restapi/config/CachesCollection.java b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
index e3d9e3c..a4b8802 100644
--- a/java/com/google/gerrit/server/restapi/config/CachesCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
@@ -20,12 +20,11 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.config.ConfigResource;
@@ -38,27 +37,23 @@
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
 @Singleton
-public class CachesCollection
-    implements ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
+public class CachesCollection implements ChildCollection<ConfigResource, CacheResource> {
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
   private final PermissionBackend permissionBackend;
   private final DynamicMap<Cache<?, ?>> cacheMap;
-  private final PostCaches postCaches;
 
   @Inject
   CachesCollection(
       DynamicMap<RestView<CacheResource>> views,
       Provider<ListCaches> list,
       PermissionBackend permissionBackend,
-      DynamicMap<Cache<?, ?>> cacheMap,
-      PostCaches postCaches) {
+      DynamicMap<Cache<?, ?>> cacheMap) {
     this.views = views;
     this.list = list;
     this.permissionBackend = permissionBackend;
     this.cacheMap = cacheMap;
-    this.postCaches = postCaches;
   }
 
   @Override
@@ -72,7 +67,7 @@
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
-    String pluginName = "gerrit";
+    String pluginName = PluginName.GERRIT;
     int i = cacheName.lastIndexOf('-');
     if (i != -1) {
       pluginName = cacheName.substring(0, i);
@@ -90,9 +85,4 @@
   public DynamicMap<RestView<CacheResource>> views() {
     return views;
   }
-
-  @Override
-  public PostCaches post(ConfigResource parent) throws RestApiException {
-    return postCaches;
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigCollection.java b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
index 934dbc1..47f4134 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
@@ -29,7 +29,7 @@
   private final DynamicMap<RestView<ConfigResource>> views;
 
   @Inject
-  ConfigCollection(DynamicMap<RestView<ConfigResource>> views) {
+  public ConfigCollection(DynamicMap<RestView<ConfigResource>> views) {
     this.views = views;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 4a89dfc..13c2818 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -44,7 +44,7 @@
   public DiffPreferencesInfo apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultDiffPreferences(git);
+      return Preferences.readDefaultDiffPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
index 44466d3..2ec547b 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -43,7 +43,7 @@
   public EditPreferencesInfo apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultEditPreferences(git);
+      return Preferences.readDefaultEditPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
index e0c54d4..4dbbc8c 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -41,7 +41,7 @@
   public GeneralPreferencesInfo apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      return Preferences.readDefaultGeneralPreferences(git);
+      return Preferences.readDefaultGeneralPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 3cd9b1b..66e9f90 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -59,11 +59,11 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.change.AllowedFormats;
 import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import java.net.MalformedURLException;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -148,43 +148,42 @@
   }
 
   @Override
-  public ServerInfo apply(ConfigResource rsrc) throws MalformedURLException {
+  public ServerInfo apply(ConfigResource rsrc) throws PermissionBackendException {
     ServerInfo info = new ServerInfo();
-    info.accounts = getAccountsInfo(accountVisibilityProvider);
-    info.auth = getAuthInfo(authConfig, realm);
-    info.change = getChangeInfo(config);
-    info.download =
-        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands, archiveFormats);
-    info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
+    info.accounts = getAccountsInfo();
+    info.auth = getAuthInfo();
+    info.change = getChangeInfo();
+    info.download = getDownloadInfo();
+    info.gerrit = getGerritInfo();
     info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
     if (Files.exists(sitePaths.site_theme)) {
       info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
     }
-    info.sshd = getSshdInfo(config);
-    info.suggest = getSuggestInfo(config);
+    info.sshd = getSshdInfo();
+    info.suggest = getSuggestInfo();
 
-    Map<String, String> urlAliases = getUrlAliasesInfo(config);
+    Map<String, String> urlAliases = getUrlAliasesInfo();
     info.urlAliases = !urlAliases.isEmpty() ? urlAliases : null;
 
-    info.user = getUserInfo(anonymousCowardName);
+    info.user = getUserInfo();
     info.receive = getReceiveInfo();
     return info;
   }
 
-  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
+  private AccountsInfo getAccountsInfo() {
     AccountsInfo info = new AccountsInfo();
     info.visibility = accountVisibilityProvider.get();
     return info;
   }
 
-  private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) {
+  private AuthInfo getAuthInfo() throws PermissionBackendException {
     AuthInfo info = new AuthInfo();
-    info.authType = cfg.getAuthType();
-    info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
+    info.authType = authConfig.getAuthType();
+    info.useContributorAgreements = toBoolean(authConfig.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
-    info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
+    info.switchAccountUrl = authConfig.getSwitchAccountUrl();
+    info.gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
@@ -200,22 +199,22 @@
     switch (info.authType) {
       case LDAP:
       case LDAP_BIND:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
         break;
 
       case CUSTOM_EXTENSION:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.httpPasswordUrl = cfg.getHttpPasswordUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
+        info.httpPasswordUrl = authConfig.getHttpPasswordUrl();
         break;
 
       case HTTP:
       case HTTP_LDAP:
-        info.loginUrl = cfg.getLoginUrl();
-        info.loginText = cfg.getLoginText();
+        info.loginUrl = authConfig.getLoginUrl();
+        info.loginText = authConfig.getLoginText();
         break;
 
       case CLIENT_SSL_CERT_LDAP:
@@ -228,40 +227,37 @@
     return info;
   }
 
-  private ChangeConfigInfo getChangeInfo(Config cfg) {
+  private ChangeConfigInfo getChangeInfo() {
     ChangeConfigInfo info = new ChangeConfigInfo();
-    info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
+    info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
     boolean hasAssigneeInIndex =
         indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
     info.showAssigneeInChangesTable =
         toBoolean(
-            cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = cfg.getInt("change", "largeChange", 500);
+            config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
+    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
-        Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
+        Optional.ofNullable(config.getString("change", null, "replyTooltip"))
+                .orElse("Reply and score")
             + " (Shortcut: a)";
     info.replyLabel =
-        Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
+        Optional.ofNullable(config.getString("change", null, "replyLabel")).orElse("Reply")
+            + "\u2026";
     info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
-    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(cfg);
+        (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
+    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(config);
     info.disablePrivateChanges =
-        toBoolean(config.getBoolean("change", null, "disablePrivateChanges", false));
+        toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
     return info;
   }
 
-  private DownloadInfo getDownloadInfo(
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands,
-      AllowedFormats archiveFormats) {
+  private DownloadInfo getDownloadInfo() {
     DownloadInfo info = new DownloadInfo();
     info.schemes = new HashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       DownloadScheme scheme = e.getProvider().get();
       if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
-        info.schemes.put(
-            e.getExportName(), getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
+        info.schemes.put(e.getExportName(), getDownloadSchemeInfo(scheme));
       }
     }
     info.archives =
@@ -269,10 +265,7 @@
     return info;
   }
 
-  private DownloadSchemeInfo getDownloadSchemeInfo(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands) {
+  private DownloadSchemeInfo getDownloadSchemeInfo(DownloadScheme scheme) {
     DownloadSchemeInfo info = new DownloadSchemeInfo();
     info.url = scheme.getUrl("${project}");
     info.isAuthRequired = toBoolean(scheme.isAuthRequired());
@@ -302,29 +295,26 @@
     return info;
   }
 
-  private GerritInfo getGerritInfo(
-      Config cfg, AllProjectsName allProjectsName, AllUsersName allUsersName) {
+  private GerritInfo getGerritInfo() {
     GerritInfo info = new GerritInfo();
     info.allProjects = allProjectsName.get();
     info.allUsers = allUsersName.get();
-    info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
-    info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
-    info.docUrl = getDocUrl(cfg);
+    info.reportBugUrl = config.getString("gerrit", null, "reportBugUrl");
+    info.reportBugText = config.getString("gerrit", null, "reportBugText");
+    info.docUrl = getDocUrl();
     info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys =
-        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+        toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.webUis = EnumSet.noneOf(UiType.class);
+    info.webUis.add(UiType.POLYGERRIT);
     if (gerritOptions.enableGwtUi()) {
       info.webUis.add(UiType.GWT);
     }
-    if (gerritOptions.enablePolyGerrit()) {
-      info.webUis.add(UiType.POLYGERRIT);
-    }
     return info;
   }
 
-  private String getDocUrl(Config cfg) {
-    String docUrl = cfg.getString("gerrit", null, "docUrl");
+  private String getDocUrl() {
+    String docUrl = config.getString("gerrit", null, "docUrl");
     if (Strings.isNullOrEmpty(docUrl)) {
       return null;
     }
@@ -352,18 +342,18 @@
     return info;
   }
 
-  private Map<String, String> getUrlAliasesInfo(Config cfg) {
+  private Map<String, String> getUrlAliasesInfo() {
     Map<String, String> urlAliases = new HashMap<>();
-    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+    for (String subsection : config.getSubsections(URL_ALIAS)) {
       urlAliases.put(
-          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+          config.getString(URL_ALIAS, subsection, KEY_MATCH),
+          config.getString(URL_ALIAS, subsection, KEY_TOKEN));
     }
     return urlAliases;
   }
 
-  private SshdInfo getSshdInfo(Config cfg) {
-    String[] addr = cfg.getStringList("sshd", null, "listenAddress");
+  private SshdInfo getSshdInfo() {
+    String[] addr = config.getStringList("sshd", null, "listenAddress");
     if (addr.length == 1 && isOff(addr[0])) {
       return null;
     }
@@ -376,13 +366,13 @@
         || "no".equalsIgnoreCase(listenHostname);
   }
 
-  private SuggestInfo getSuggestInfo(Config cfg) {
+  private SuggestInfo getSuggestInfo() {
     SuggestInfo info = new SuggestInfo();
-    info.from = cfg.getInt("suggest", "from", 0);
+    info.from = config.getInt("suggest", "from", 0);
     return info;
   }
 
-  private UserConfigInfo getUserInfo(String anonymousCowardName) {
+  private UserConfigInfo getUserInfo() {
     UserConfigInfo info = new UserConfigInfo();
     info.anonymousCowardName = anonymousCowardName;
     return info;
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index c0a9d71..38664fb 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
 import static com.google.gerrit.server.config.CacheResource.cacheNameOf;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.base.Joiner;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheStats;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -29,11 +31,9 @@
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
@@ -72,19 +72,17 @@
     if (format == null) {
       return getCacheInfos();
     }
-    List<String> cacheNames = new ArrayList<>();
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
-    }
-    Collections.sort(cacheNames);
-
+    Stream<String> cacheNames =
+        Streams.stream(cacheMap)
+            .map(e -> cacheNameOf(e.getPluginName(), e.getExportName()))
+            .sorted();
     if (OutputFormat.TEXT_LIST.equals(format)) {
-      return BinaryResult.create(Joiner.on('\n').join(cacheNames))
+      return BinaryResult.create(cacheNames.collect(joining("\n")))
           .base64()
           .setContentType("text/plain")
           .setCharacterEncoding(UTF_8);
     }
-    return cacheNames;
+    return cacheNames.collect(toImmutableList());
   }
 
   public enum CacheType {
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index d700028..f77cda4 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.restapi.config;
 
-import com.google.common.collect.ComparisonChain;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -23,20 +25,18 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -106,20 +106,14 @@
   }
 
   private List<TaskInfo> getTasks() {
-    List<TaskInfo> taskInfos = workQueue.getTaskInfos(TaskInfo::new);
-    Collections.sort(
-        taskInfos,
-        new Comparator<TaskInfo>() {
-          @Override
-          public int compare(TaskInfo a, TaskInfo b) {
-            return ComparisonChain.start()
-                .compare(a.state.ordinal(), b.state.ordinal())
-                .compare(a.delay, b.delay)
-                .compare(a.command, b.command)
-                .result();
-          }
-        });
-    return taskInfos;
+    return workQueue
+        .getTaskInfos(TaskInfo::new)
+        .stream()
+        .sorted(
+            comparing((TaskInfo t) -> t.state.ordinal())
+                .thenComparing(t -> t.delay)
+                .thenComparing(t -> t.command))
+        .collect(toList());
   }
 
   public static class TaskInfo {
@@ -133,7 +127,7 @@
     public String queueName;
 
     public TaskInfo(Task<?> task) {
-      this.id = IdGenerator.format(task.getTaskId());
+      this.id = HexFormat.fromInt(task.getTaskId());
       this.state = task.getState();
       this.startTime = new Timestamp(task.getStartTime().getTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index f21672c..57ba097 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -20,10 +20,11 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.config.ConfigResource;
@@ -36,7 +37,7 @@
 
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
 @Singleton
-public class PostCaches implements RestModifyView<ConfigResource, Input> {
+public class PostCaches implements RestCollectionModifyView<ConfigResource, CacheResource, Input> {
   public static class Input {
     public Operation operation;
     public List<String> caches;
@@ -110,7 +111,7 @@
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
-      String pluginName = "gerrit";
+      String pluginName = PluginName.GERRIT;
       String cacheName = n;
       int i = cacheName.lastIndexOf('-');
       if (i != -1) {
diff --git a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
index 7283033..c929bc6 100644
--- a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
+++ b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
@@ -26,6 +26,7 @@
   protected void configure() {
     DynamicMap.mapOf(binder(), CACHE_KIND);
     child(CONFIG_KIND, "caches").to(CachesCollection.class);
+    postOnCollection(CACHE_KIND).to(PostCaches.class);
     get(CACHE_KIND).to(GetCache.class);
     post(CACHE_KIND, "flush").to(FlushCache.class);
     get(CONFIG_KIND, "summary").to(GetSummary.class);
diff --git a/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
index 36a1b04..cca1475 100644
--- a/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
@@ -25,7 +25,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class TopMenuCollection implements ChildCollection<ConfigResource, TopMenuResource> {
+public class TopMenuCollection implements ChildCollection<ConfigResource, TopMenuResource> {
   private final DynamicMap<RestView<TopMenuResource>> views;
   private final ListTopMenus list;
 
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 9ddafe3..a897e1a 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -44,6 +46,7 @@
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.gwtorm.server.OrmException;
@@ -114,7 +117,8 @@
   @Override
   public List<AccountInfo> apply(GroupResource resource, Input input)
       throws AuthException, NotInternalGroupException, UnprocessableEntityException, OrmException,
-          IOException, ConfigInvalidException, ResourceNotFoundException {
+          IOException, ConfigInvalidException, ResourceNotFoundException,
+          PermissionBackendException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     input = Input.init(input);
@@ -201,7 +205,8 @@
     }
   }
 
-  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds) throws OrmException {
+  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds)
+      throws PermissionBackendException {
     List<AccountInfo> result = new ArrayList<>();
     AccountLoader loader = infoFactory.create(true);
     for (Account.Id accId : accountIds) {
@@ -211,22 +216,22 @@
     return result;
   }
 
-  static class PutMember implements RestModifyView<GroupResource, Input> {
-
+  @Singleton
+  public static class CreateMember
+      implements RestCollectionCreateView<GroupResource, MemberResource, Input> {
     private final AddMembers put;
-    private final String id;
 
-    PutMember(AddMembers put, String id) {
+    @Inject
+    public CreateMember(AddMembers put) {
       this.put = put;
-      this.id = id;
     }
 
     @Override
-    public AccountInfo apply(GroupResource resource, Input input)
+    public AccountInfo apply(GroupResource resource, IdString id, Input input)
         throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException, ConfigInvalidException {
+            IOException, ConfigInvalidException, PermissionBackendException {
       AddMembers.Input in = new AddMembers.Input();
-      in._oneMember = id;
+      in._oneMember = id.get();
       try {
         List<AccountInfo> list = put.apply(resource, in);
         if (list.size() == 1) {
@@ -240,16 +245,17 @@
   }
 
   @Singleton
-  static class UpdateMember implements RestModifyView<MemberResource, Input> {
+  public static class UpdateMember implements RestModifyView<MemberResource, Input> {
     private final GetMember get;
 
     @Inject
-    UpdateMember(GetMember get) {
+    public UpdateMember(GetMember get) {
       this.get = get;
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, Input input) throws OrmException {
+    public AccountInfo apply(MemberResource resource, Input input)
+        throws OrmException, PermissionBackendException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index e11f389..ca77ebf 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -90,7 +93,8 @@
   @Override
   public List<GroupInfo> apply(GroupResource resource, Input input)
       throws NotInternalGroupException, AuthException, UnprocessableEntityException, OrmException,
-          ResourceNotFoundException, IOException, ConfigInvalidException {
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     input = Input.init(input);
@@ -127,22 +131,22 @@
     groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
   }
 
-  static class PutSubgroup implements RestModifyView<GroupResource, Input> {
-
+  @Singleton
+  public static class CreateSubgroup
+      implements RestCollectionCreateView<GroupResource, SubgroupResource, Input> {
     private final AddSubgroups addSubgroups;
-    private final String id;
 
-    PutSubgroup(AddSubgroups addSubgroups, String id) {
+    @Inject
+    public CreateSubgroup(AddSubgroups addSubgroups) {
       this.addSubgroups = addSubgroups;
-      this.id = id;
     }
 
     @Override
-    public GroupInfo apply(GroupResource resource, Input input)
+    public GroupInfo apply(GroupResource resource, IdString id, Input input)
         throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException, ConfigInvalidException {
+            IOException, ConfigInvalidException, PermissionBackendException {
       AddSubgroups.Input in = new AddSubgroups.Input();
-      in.groups = ImmutableList.of(id);
+      in.groups = ImmutableList.of(id.get());
       try {
         List<GroupInfo> list = addSubgroups.apply(resource, in);
         if (list.size() == 1) {
@@ -156,16 +160,17 @@
   }
 
   @Singleton
-  static class UpdateSubgroup implements RestModifyView<SubgroupResource, Input> {
+  public static class UpdateSubgroup implements RestModifyView<SubgroupResource, Input> {
     private final Provider<GetSubgroup> get;
 
     @Inject
-    UpdateSubgroup(Provider<GetSubgroup> get) {
+    public UpdateSubgroup(Provider<GetSubgroup> get) {
       this.get = get;
     }
 
     @Override
-    public GroupInfo apply(SubgroupResource resource, Input input) throws OrmException {
+    public GroupInfo apply(SubgroupResource resource, Input input)
+        throws OrmException, PermissionBackendException {
       // Do nothing, the group is already included.
       return get.get().apply(resource);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 79f9688..64d515c 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -26,9 +26,10 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
@@ -42,19 +43,21 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 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.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -67,11 +70,9 @@
 import org.eclipse.jgit.lib.PersonIdent;
 
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
-public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
-  public interface Factory {
-    CreateGroup create(@Assisted String name);
-  }
-
+@Singleton
+public class CreateGroup
+    implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
   private final PersonIdent serverIdent;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
@@ -82,7 +83,6 @@
   private final AddMembers addMembers;
   private final SystemGroupBackend systemGroupBackend;
   private final boolean defaultVisibleToAll;
-  private final String name;
   private final Sequences sequences;
 
   @Inject
@@ -97,7 +97,6 @@
       AddMembers addMembers,
       SystemGroupBackend systemGroupBackend,
       @GerritServerConfig Config cfg,
-      @Assisted String name,
       Sequences sequences) {
     this.self = self;
     this.serverIdent = serverIdent;
@@ -109,7 +108,6 @@
     this.addMembers = addMembers;
     this.systemGroupBackend = systemGroupBackend;
     this.defaultVisibleToAll = cfg.getBoolean("groups", "newGroupsVisibleToAll", false);
-    this.name = name;
     this.sequences = sequences;
   }
 
@@ -124,10 +122,11 @@
   }
 
   @Override
-  public GroupInfo apply(TopLevelResource resource, GroupInput input)
+  public GroupInfo apply(TopLevelResource resource, IdString id, GroupInput input)
       throws AuthException, BadRequestException, UnprocessableEntityException,
           ResourceConflictException, OrmException, IOException, ConfigInvalidException,
-          ResourceNotFoundException {
+          ResourceNotFoundException, PermissionBackendException {
+    String name = id.get();
     if (input == null) {
       input = new GroupInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index 3685469..bcacb65 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -92,12 +92,12 @@
   }
 
   @Singleton
-  static class DeleteMember implements RestModifyView<MemberResource, Input> {
+  public static class DeleteMember implements RestModifyView<MemberResource, Input> {
 
     private final Provider<DeleteMembers> delete;
 
     @Inject
-    DeleteMember(Provider<DeleteMembers> delete) {
+    public DeleteMember(Provider<DeleteMembers> delete) {
       this.delete = delete;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index 0eba8c7..934698b 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -96,12 +96,12 @@
   }
 
   @Singleton
-  static class DeleteSubgroup implements RestModifyView<SubgroupResource, Input> {
+  public static class DeleteSubgroup implements RestModifyView<SubgroupResource, Input> {
 
     private final Provider<DeleteSubgroups> delete;
 
     @Inject
-    DeleteSubgroup(Provider<DeleteSubgroups> delete) {
+    public DeleteSubgroup(Provider<DeleteSubgroups> delete) {
       this.delete = delete;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index eb66a37..dcdd8a8 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -35,12 +35,12 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -77,7 +77,7 @@
   @Override
   public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
       throws AuthException, NotInternalGroupException, OrmException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (!rsrc.getControl().isOwner()) {
@@ -137,7 +137,7 @@
     accountLoader.fill();
 
     // sort by date and then reverse so that the newest audit event comes first
-    Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date).reversed());
+    auditEvents.sort(comparing((GroupAuditEventInfo a) -> a.date).reversed());
     return auditEvents;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetDetail.java b/java/com/google/gerrit/server/restapi/group/GetDetail.java
index e7b240e..75d1e34 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDetail.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource rsrc) throws OrmException {
+  public GroupInfo apply(GroupResource rsrc) throws OrmException, PermissionBackendException {
     return json.format(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetGroup.java b/java/com/google/gerrit/server/restapi/group/GetGroup.java
index 81057fd..c6cddb6 100644
--- a/java/com/google/gerrit/server/restapi/group/GetGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetGroup.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource) throws OrmException {
+  public GroupInfo apply(GroupResource resource) throws OrmException, PermissionBackendException {
     return json.format(resource.getGroup());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GetMember.java b/java/com/google/gerrit/server/restapi/group/GetMember.java
index db33785..95063de 100644
--- a/java/com/google/gerrit/server/restapi/group/GetMember.java
+++ b/java/com/google/gerrit/server/restapi/group/GetMember.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -32,7 +33,7 @@
   }
 
   @Override
-  public AccountInfo apply(MemberResource rsrc) throws OrmException {
+  public AccountInfo apply(MemberResource rsrc) throws OrmException, PermissionBackendException {
     AccountLoader loader = infoFactory.create(true);
     AccountInfo info = loader.get(rsrc.getMember().getAccountId());
     loader.fill();
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
index be19a24..0906ce6 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,7 +40,8 @@
 
   @Override
   public GroupInfo apply(GroupResource resource)
-      throws NotInternalGroupException, ResourceNotFoundException, OrmException {
+      throws NotInternalGroupException, ResourceNotFoundException, OrmException,
+          PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     try {
diff --git a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
index 98e6ce5..16e2739 100644
--- a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
+++ b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +32,7 @@
   }
 
   @Override
-  public GroupInfo apply(SubgroupResource rsrc) throws OrmException {
+  public GroupInfo apply(SubgroupResource rsrc) throws OrmException, PermissionBackendException {
     return json.format(rsrc.getMemberDescription());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 3c7799b..a51fad2 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,17 +76,18 @@
     return this;
   }
 
-  public GroupInfo format(GroupResource rsrc) throws OrmException {
+  public GroupInfo format(GroupResource rsrc) throws OrmException, PermissionBackendException {
     return createGroupInfo(rsrc.getGroup(), rsrc::getControl);
   }
 
-  public GroupInfo format(GroupDescription.Basic group) throws OrmException {
+  public GroupInfo format(GroupDescription.Basic group)
+      throws OrmException, PermissionBackendException {
     return createGroupInfo(group, Suppliers.memoize(() -> groupControlFactory.controlFor(group)));
   }
 
   private GroupInfo createGroupInfo(
       GroupDescription.Basic group, Supplier<GroupControl> groupControlSupplier)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     GroupInfo info = createBasicGroupInfo(group);
 
     if (group instanceof GroupDescription.Internal) {
@@ -108,7 +110,7 @@
       GroupInfo info,
       GroupDescription.Internal internalGroup,
       Supplier<GroupControl> groupControlSupplier)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     info.description = Strings.emptyToNull(internalGroup.getDescription());
     info.groupId = internalGroup.getId().get();
 
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 38b22a9..40a11c7 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -43,13 +42,10 @@
 import java.util.Optional;
 
 public class GroupsCollection
-    implements RestCollection<TopLevelResource, GroupResource>,
-        AcceptsCreate<TopLevelResource>,
-        NeedsParams {
+    implements RestCollection<TopLevelResource, GroupResource>, NeedsParams {
   private final DynamicMap<RestView<GroupResource>> views;
   private final Provider<ListGroups> list;
   private final Provider<QueryGroups> queryGroups;
-  private final CreateGroup.Factory createGroup;
   private final GroupControl.Factory groupControlFactory;
   private final GroupBackend groupBackend;
   private final GroupCache groupCache;
@@ -58,11 +54,10 @@
   private boolean hasQuery2;
 
   @Inject
-  GroupsCollection(
+  public GroupsCollection(
       DynamicMap<RestView<GroupResource>> views,
       Provider<ListGroups> list,
       Provider<QueryGroups> queryGroups,
-      CreateGroup.Factory createGroup,
       GroupControl.Factory groupControlFactory,
       GroupBackend groupBackend,
       GroupCache groupCache,
@@ -70,7 +65,6 @@
     this.views = views;
     this.list = list;
     this.queryGroups = queryGroups;
-    this.createGroup = createGroup;
     this.groupControlFactory = groupControlFactory;
     this.groupBackend = groupBackend;
     this.groupCache = groupCache;
@@ -199,11 +193,6 @@
   }
 
   @Override
-  public CreateGroup create(TopLevelResource root, IdString name) {
-    return createGroup.create(name.get());
-  }
-
-  @Override
   public DynamicMap<RestView<GroupResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index c7f1d5e..bae5eff 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.account.GetGroups;
 import com.google.gwtorm.server.OrmException;
@@ -246,7 +247,8 @@
 
   @Override
   public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
@@ -256,7 +258,8 @@
   }
 
   public List<GroupInfo> get()
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
@@ -280,7 +283,8 @@
     return getAllGroups();
   }
 
-  private List<GroupInfo> getAllGroups() throws OrmException, IOException, ConfigInvalidException {
+  private List<GroupInfo> getAllGroups()
+      throws OrmException, IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> existingGroups =
         getAllExistingGroups()
@@ -312,7 +316,8 @@
     return groups.getAllGroupReferences();
   }
 
-  private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
+  private List<GroupInfo> suggestGroups()
+      throws OrmException, BadRequestException, PermissionBackendException {
     if (conflictingSuggestParameters()) {
       throw new BadRequestException(
           "You should only have no more than one --project and -n with --suggest");
@@ -368,7 +373,7 @@
   }
 
   private List<GroupInfo> filterGroupsOwnedBy(Predicate<GroupDescription.Internal> filter)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws OrmException, IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<? extends GroupDescription.Internal> foundGroups =
         groups
@@ -396,13 +401,14 @@
   }
 
   private List<GroupInfo> getGroupsOwnedBy(String id)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     String uuid = groupsCollection.parse(id).getGroupUUID().get();
     return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
   }
 
   private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
-      throws OrmException, IOException, ConfigInvalidException {
+      throws OrmException, IOException, ConfigInvalidException, PermissionBackendException {
     return filterGroupsOwnedBy(group -> isOwner(user, group));
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 7c68aab..4742644 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -66,7 +67,7 @@
 
   @Override
   public List<AccountInfo> apply(GroupResource resource)
-      throws NotInternalGroupException, OrmException {
+      throws NotInternalGroupException, OrmException, PermissionBackendException {
     GroupDescription.Internal group =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (recursive) {
@@ -75,7 +76,8 @@
     return getDirectMembers(group, resource.getControl());
   }
 
-  public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid) throws OrmException {
+  public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid)
+      throws PermissionBackendException {
     Optional<InternalGroup> group = groupCache.get(groupUuid);
     if (group.isPresent()) {
       InternalGroupDescription internalGroup = new InternalGroupDescription(group.get());
@@ -86,7 +88,8 @@
   }
 
   private List<AccountInfo> getTransitiveMembers(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws PermissionBackendException {
     checkSameGroup(group, groupControl);
     Set<Account.Id> members =
         getTransitiveMemberIds(
@@ -94,19 +97,21 @@
     return toAccountInfos(members);
   }
 
-  public List<AccountInfo> getDirectMembers(InternalGroup group) throws OrmException {
+  public List<AccountInfo> getDirectMembers(InternalGroup group) throws PermissionBackendException {
     InternalGroupDescription internalGroup = new InternalGroupDescription(group);
     return getDirectMembers(internalGroup, groupControlFactory.controlFor(internalGroup));
   }
 
   public List<AccountInfo> getDirectMembers(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws PermissionBackendException {
     checkSameGroup(group, groupControl);
     Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
     return toAccountInfos(directMembers);
   }
 
-  private List<AccountInfo> toAccountInfos(Set<Account.Id> members) throws OrmException {
+  private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
+      throws PermissionBackendException {
     List<AccountInfo> memberInfos = new ArrayList<>(members.size());
     for (Account.Id member : members) {
       memberInfos.add(accountLoader.get(member));
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index 835a613..864b01b 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import static com.google.common.base.Strings.nullToEmpty;
+import static java.util.Comparator.comparing;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
@@ -24,12 +25,11 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 @Singleton
@@ -46,7 +46,8 @@
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource rsrc) throws NotInternalGroupException, OrmException {
+  public List<GroupInfo> apply(GroupResource rsrc)
+      throws NotInternalGroupException, OrmException, PermissionBackendException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(NotInternalGroupException::new);
 
@@ -54,7 +55,8 @@
   }
 
   public List<GroupInfo> getDirectSubgroups(
-      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+      GroupDescription.Internal group, GroupControl groupControl)
+      throws OrmException, PermissionBackendException {
     boolean ownerOfParent = groupControl.isOwner();
     List<GroupInfo> included = new ArrayList<>();
     for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
@@ -69,18 +71,8 @@
         continue;
       }
     }
-    Collections.sort(
-        included,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-            if (cmp != 0) {
-              return cmp;
-            }
-            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-          }
-        });
+    included.sort(
+        comparing((GroupInfo g) -> nullToEmpty(g.name)).thenComparing(g -> nullToEmpty(g.id)));
     return included;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/MembersCollection.java b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
index cf2e0b8..fec1443 100644
--- a/java/com/google/gerrit/server/restapi/group/MembersCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -27,7 +26,6 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.group.AddMembers.PutMember;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -36,23 +34,19 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class MembersCollection
-    implements ChildCollection<GroupResource, MemberResource>, AcceptsCreate<GroupResource> {
+public class MembersCollection implements ChildCollection<GroupResource, MemberResource> {
   private final DynamicMap<RestView<MemberResource>> views;
   private final Provider<ListMembers> list;
   private final AccountsCollection accounts;
-  private final AddMembers put;
 
   @Inject
   MembersCollection(
       DynamicMap<RestView<MemberResource>> views,
       Provider<ListMembers> list,
-      AccountsCollection accounts,
-      AddMembers put) {
+      AccountsCollection accounts) {
     this.views = views;
     this.list = list;
     this.accounts = accounts;
-    this.put = put;
   }
 
   @Override
@@ -79,11 +73,6 @@
   }
 
   @Override
-  public PutMember create(GroupResource group, IdString id) {
-    return new PutMember(put, id.get());
-  }
-
-  @Override
   public DynamicMap<RestView<MemberResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/Module.java b/java/com/google/gerrit/server/restapi/group/Module.java
index fa1e5c7..741c3da 100644
--- a/java/com/google/gerrit/server/restapi/group/Module.java
+++ b/java/com/google/gerrit/server/restapi/group/Module.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.restapi.group.AddMembers.CreateMember;
 import com.google.gerrit.server.restapi.group.AddMembers.UpdateMember;
+import com.google.gerrit.server.restapi.group.AddSubgroups.CreateSubgroup;
 import com.google.gerrit.server.restapi.group.AddSubgroups.UpdateSubgroup;
 import com.google.gerrit.server.restapi.group.DeleteMembers.DeleteMember;
 import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
@@ -40,6 +42,7 @@
     DynamicMap.mapOf(binder(), MEMBER_KIND);
     DynamicMap.mapOf(binder(), SUBGROUP_KIND);
 
+    create(GROUP_KIND).to(CreateGroup.class);
     get(GROUP_KIND).to(GetGroup.class);
     put(GROUP_KIND).to(PutGroup.class);
     get(GROUP_KIND, "detail").to(GetDetail.class);
@@ -62,16 +65,17 @@
     get(GROUP_KIND, "log.audit").to(GetAuditLog.class);
 
     child(GROUP_KIND, "members").to(MembersCollection.class);
+    create(MEMBER_KIND).to(CreateMember.class);
     get(MEMBER_KIND).to(GetMember.class);
     put(MEMBER_KIND).to(UpdateMember.class);
     delete(MEMBER_KIND).to(DeleteMember.class);
 
     child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
+    create(SUBGROUP_KIND).to(CreateSubgroup.class);
     get(SUBGROUP_KIND).to(GetSubgroup.class);
     put(SUBGROUP_KIND).to(UpdateSubgroup.class);
     delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
 
-    factory(CreateGroup.Factory.class);
     factory(GroupsUpdate.Factory.class);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 5e7563e..56f856d 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,7 +57,7 @@
   public GroupInfo apply(GroupResource resource, OwnerInput input)
       throws ResourceNotFoundException, NotInternalGroupException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     GroupDescription.Internal internalGroup =
         resource.asInternalGroup().orElseThrow(NotInternalGroupException::new);
     if (!resource.getControl().isOwner()) {
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index c262003..fa9285d 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.group.GroupQueryBuilder;
 import com.google.gerrit.server.query.group.GroupQueryProcessor;
 import com.google.gwtorm.server.OrmException;
@@ -94,7 +95,8 @@
 
   @Override
   public List<GroupInfo> apply(TopLevelResource resource)
-      throws BadRequestException, MethodNotAllowedException, OrmException {
+      throws BadRequestException, MethodNotAllowedException, OrmException,
+          PermissionBackendException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
diff --git a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
index 83520f1..cebc27a 100644
--- a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -25,28 +24,23 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
-import com.google.gerrit.server.restapi.group.AddSubgroups.PutSubgroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SubgroupsCollection
-    implements ChildCollection<GroupResource, SubgroupResource>, AcceptsCreate<GroupResource> {
+public class SubgroupsCollection implements ChildCollection<GroupResource, SubgroupResource> {
   private final DynamicMap<RestView<SubgroupResource>> views;
   private final ListSubgroups list;
   private final GroupsCollection groupsCollection;
-  private final AddSubgroups addSubgroups;
 
   @Inject
   SubgroupsCollection(
       DynamicMap<RestView<SubgroupResource>> views,
       ListSubgroups list,
-      GroupsCollection groupsCollection,
-      AddSubgroups addSubgroups) {
+      GroupsCollection groupsCollection) {
     this.views = views;
     this.list = list;
     this.groupsCollection = groupsCollection;
-    this.addSubgroups = addSubgroups;
   }
 
   @Override
@@ -74,11 +68,6 @@
   }
 
   @Override
-  public PutSubgroup create(GroupResource group, IdString id) {
-    return new PutSubgroup(addSubgroups, id.get());
-  }
-
-  @Override
   public DynamicMap<RestView<SubgroupResource>> views() {
     return views;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index f8ff7b9..389cc2f 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -39,26 +38,22 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class BranchesCollection
-    implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
+public class BranchesCollection implements ChildCollection<ProjectResource, BranchResource> {
   private final DynamicMap<RestView<BranchResource>> views;
   private final Provider<ListBranches> list;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
-  private final CreateBranch.Factory createBranchFactory;
 
   @Inject
   BranchesCollection(
       DynamicMap<RestView<BranchResource>> views,
       Provider<ListBranches> list,
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      CreateBranch.Factory createBranchFactory) {
+      GitRepositoryManager repoManager) {
     this.views = views;
     this.list = list;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
-    this.createBranchFactory = createBranchFactory;
   }
 
   @Override
@@ -98,9 +93,4 @@
   public DynamicMap<RestView<BranchResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateBranch create(ProjectResource parent, IdString name) {
-    return createBranchFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Check.java b/java/com/google/gerrit/server/restapi/project/Check.java
new file mode 100644
index 0000000..a6fd764
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Check.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 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.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class Check implements RestModifyView<ProjectResource, CheckProjectInput> {
+  private final PermissionBackend permissionBackend;
+  private final ProjectsConsistencyChecker projectsConsistencyChecker;
+
+  @Inject
+  Check(
+      PermissionBackend permissionBackend, ProjectsConsistencyChecker projectsConsistencyChecker) {
+    this.permissionBackend = permissionBackend;
+    this.projectsConsistencyChecker = projectsConsistencyChecker;
+  }
+
+  @Override
+  public CheckProjectResultInfo apply(ProjectResource rsrc, CheckProjectInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+    return projectsConsistencyChecker.check(rsrc.getNameKey(), input);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 865f077..ecbb8e4 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
@@ -48,18 +47,15 @@
 @Singleton
 public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
   private final AccountResolver accountResolver;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
 
   @Inject
   CheckAccess(
       AccountResolver resolver,
-      IdentifiedUser.GenericFactory userFactory,
       PermissionBackend permissionBackend,
       GitRepositoryManager gitRepositoryManager) {
     this.accountResolver = resolver;
-    this.userFactory = userFactory;
     this.permissionBackend = permissionBackend;
     this.gitRepositoryManager = gitRepositoryManager;
   }
@@ -86,15 +82,13 @@
     }
 
     AccessCheckInfo info = new AccessCheckInfo();
-
-    IdentifiedUser user = userFactory.create(match.getId());
     try {
-      permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.ACCESS);
+      permissionBackend
+          .absentUser(match.getId())
+          .project(rsrc.getNameKey())
+          .check(ProjectPermission.ACCESS);
     } catch (AuthException e) {
-      info.message =
-          String.format(
-              "user %s (%s) cannot see project %s",
-              user.getNameEmail(), user.getAccount().getId(), rsrc.getName());
+      info.message = String.format("user %s cannot see project %s", match.getId(), rsrc.getName());
       info.status = HttpServletResponse.SC_FORBIDDEN;
       return info;
     }
@@ -118,26 +112,22 @@
     if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
-            .user(user)
+            .absentUser(match.getId())
             .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
             .check(refPerm);
       } catch (AuthException e) {
         info.status = HttpServletResponse.SC_FORBIDDEN;
         info.message =
             String.format(
-                "user %s (%s) lacks permission %s for %s in project %s",
-                user.getNameEmail(),
-                user.getAccount().getId(),
-                input.permission,
-                input.ref,
-                rsrc.getName());
+                "user %s lacks permission %s for %s in project %s",
+                match.getId(), input.permission, input.ref, rsrc.getName());
         return info;
       }
     } else {
       // We say access is okay if there are no refs, but this warrants a warning,
       // as access denied looks the same as no branches to the user.
       try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefs(REFS_HEADS).isEmpty()) {
+        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
           info.message = "access is OK, but repository has no branches under refs/heads/";
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 0d52090..60b5dee 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -33,10 +33,10 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.ProjectState.EffectiveMaxObjectSizeLimit;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -48,7 +48,6 @@
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -72,14 +71,7 @@
       this.requireSignedPush = null;
     }
 
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config.getMaxObjectSizeLimit()
-            ? config.getFormattedMaxObjectSizeLimit()
-            : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue = config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
+    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
     this.defaultSubmitType = new SubmitTypeInfo();
     this.defaultSubmitType.value = projectState.getSubmitType();
@@ -114,6 +106,16 @@
     this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
+  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+    MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
+    EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
+    long value = limit.value;
+    info.value = value == 0 ? null : String.valueOf(value);
+    info.configuredValue = p.getMaxObjectSizeLimit();
+    info.summary = limit.summary;
+    return info;
+  }
+
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 1529dae..33155f1 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -157,6 +157,8 @@
         bu.execute();
         return Response.created(jsonFactory.noOptions().format(ins.getChange()));
       }
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 0296c9c..62106e8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -21,8 +21,9 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
@@ -31,6 +32,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.CreateRefControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectResource;
@@ -39,7 +41,7 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -50,20 +52,17 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
+@Singleton
+public class CreateBranch
+    implements RestCollectionCreateView<ProjectResource, BranchResource, BranchInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    CreateBranch create(String ref);
-  }
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refCreationValidator;
   private final CreateRefControl createRefControl;
-  private String ref;
 
   @Inject
   CreateBranch(
@@ -72,21 +71,20 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refHelperFactory,
-      CreateRefControl createRefControl,
-      @Assisted String ref) {
+      CreateRefControl createRefControl) {
     this.identifiedUser = identifiedUser;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
     this.createRefControl = createRefControl;
-    this.ref = ref;
   }
 
   @Override
-  public BranchInfo apply(ProjectResource rsrc, BranchInput input)
+  public BranchInfo apply(ProjectResource rsrc, IdString id, BranchInput input)
       throws BadRequestException, AuthException, ResourceConflictException, IOException,
           PermissionBackendException, NoSuchProjectException {
+    String ref = id.get();
     if (input == null) {
       input = new BranchInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
new file mode 100644
index 0000000..e8b6236
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 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.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.kohsuke.args4j.Option;
+
+@Singleton
+public class CreateDashboard
+    implements RestCollectionCreateView<ProjectResource, DashboardResource, SetDashboardInput> {
+  private final Provider<SetDefaultDashboard> setDefault;
+
+  @Option(name = "--inherited", usage = "set dashboard inherited by children")
+  private boolean inherited;
+
+  @Inject
+  CreateDashboard(Provider<SetDefaultDashboard> setDefault) {
+    this.setDefault = setDefault;
+  }
+
+  @Override
+  public Response<DashboardInfo> apply(ProjectResource parent, IdString id, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
+    parent.getProjectState().checkStatePermitsWrite();
+    if (!DashboardsCollection.isDefaultDashboard(id)) {
+      throw new ResourceNotFoundException(id);
+    }
+    SetDefaultDashboard set = setDefault.get();
+    set.inherited = inherited;
+    return set.apply(
+        DashboardResource.projectDefault(parent.getProjectState(), parent.getUser()), input);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 3a9a0e7..271848b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -37,10 +37,11 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 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.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
@@ -64,13 +65,14 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectNameLockManager;
+import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -89,13 +91,11 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
-public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
+@Singleton
+public class CreateProject
+    implements RestCollectionCreateView<TopLevelResource, ProjectResource, ProjectInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    CreateProject create(String name);
-  }
-
   private final Provider<ProjectsCollection> projectsCollection;
   private final Provider<GroupsCollection> groupsCollection;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -114,7 +114,6 @@
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
   private final DynamicItem<ProjectNameLockManager> lockManager;
-  private final String name;
 
   @Inject
   CreateProject(
@@ -135,8 +134,7 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      DynamicItem<ProjectNameLockManager> lockManager,
-      @Assisted String name) {
+      DynamicItem<ProjectNameLockManager> lockManager) {
     this.projectsCollection = projectsCollection;
     this.groupsCollection = groupsCollection;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -155,12 +153,12 @@
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
-    this.name = name;
   }
 
   @Override
-  public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
+  public Response<ProjectInfo> apply(TopLevelResource resource, IdString id, ProjectInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    String name = id.get();
     if (input == null) {
       input = new ProjectInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index b09d870..dd73b0b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -23,10 +23,11 @@
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -38,8 +39,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
+import com.google.gerrit.server.project.TagResource;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.TimeZone;
 import org.eclipse.jgit.api.Git;
@@ -52,19 +54,14 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
+@Singleton
+public class CreateTag implements RestCollectionCreateView<ProjectResource, TagResource, TagInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    CreateTag create(String ref);
-  }
-
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final TagCache tagCache;
   private final GitReferenceUpdated referenceUpdated;
   private final WebLinks links;
-  private String ref;
 
   @Inject
   CreateTag(
@@ -72,19 +69,18 @@
       GitRepositoryManager repoManager,
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
-      WebLinks webLinks,
-      @Assisted String ref) {
+      WebLinks webLinks) {
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.tagCache = tagCache;
     this.referenceUpdated = referenceUpdated;
     this.links = webLinks;
-    this.ref = ref;
   }
 
   @Override
-  public TagInfo apply(ProjectResource resource, TagInput input)
+  public TagInfo apply(ProjectResource resource, IdString id, TagInput input)
       throws RestApiException, IOException, PermissionBackendException, NoSuchProjectException {
+    String ref = id.get();
     if (input == null) {
       input = new TagInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index b7589cf..07691e7 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -25,14 +25,12 @@
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Project;
@@ -60,14 +58,12 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class DashboardsCollection
-    implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
+public class DashboardsCollection implements ChildCollection<ProjectResource, DashboardResource> {
   public static final String DEFAULT_DASHBOARD_NAME = "default";
 
   private final GitRepositoryManager gitManager;
   private final DynamicMap<RestView<DashboardResource>> views;
   private final Provider<ListDashboards> list;
-  private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
   private final PermissionBackend permissionBackend;
 
   @Inject
@@ -75,12 +71,10 @@
       GitRepositoryManager gitManager,
       DynamicMap<RestView<DashboardResource>> views,
       Provider<ListDashboards> list,
-      Provider<SetDefaultDashboard.CreateDefault> createDefault,
       PermissionBackend permissionBackend) {
     this.gitManager = gitManager;
     this.views = views;
     this.list = list;
-    this.createDefault = createDefault;
     this.permissionBackend = permissionBackend;
   }
 
@@ -98,16 +92,6 @@
   }
 
   @Override
-  public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
-      throws RestApiException {
-    parent.getProjectState().checkStatePermitsWrite();
-    if (isDefaultDashboard(id)) {
-      return createDefault.get();
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
   public DashboardResource parse(ProjectResource parent, IdString id)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     parent.getProjectState().checkStatePermitsRead();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index aed372c..0134ce3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -23,9 +23,7 @@
 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.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -38,17 +36,12 @@
 public class DeleteBranch implements RestModifyView<BranchResource, Input> {
 
   private final Provider<InternalChangeQuery> queryProvider;
-  private final DeleteRef.Factory deleteRefFactory;
-  private final PermissionBackend permissionBackend;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteBranch(
-      Provider<InternalChangeQuery> queryProvider,
-      DeleteRef.Factory deleteRefFactory,
-      PermissionBackend permissionBackend) {
+  DeleteBranch(Provider<InternalChangeQuery> queryProvider, DeleteRef deleteRef) {
     this.queryProvider = queryProvider;
-    this.deleteRefFactory = deleteRefFactory;
-    this.permissionBackend = permissionBackend;
+    this.deleteRef = deleteRef;
   }
 
   @Override
@@ -60,14 +53,11 @@
           "not allowed to delete branch " + rsrc.getBranchKey().get());
     }
 
-    permissionBackend.currentUser().ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
-    rsrc.getProjectState().checkStatePermitsWrite();
-
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
       throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
     }
 
-    deleteRefFactory.create(rsrc).ref(rsrc.getRef()).prefix(R_HEADS).delete();
+    deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
index d8166e1..6e60193 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -30,11 +31,11 @@
 
 @Singleton
 public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBranchesInput> {
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteBranches(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteBranches(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
@@ -43,7 +44,8 @@
     if (input == null || input.branches == null || input.branches.isEmpty()) {
       throw new BadRequestException("branches must be specified");
     }
-    deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
+    deleteRef.deleteMultipleRefs(
+        project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
index b9b69b2..2702d58 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 769eaf8..b7fbff4 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -14,33 +14,35 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 import static java.lang.String.format;
-import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 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.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.errors.LockFailedException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -52,6 +54,7 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
+@Singleton
 public class DeleteRef {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -64,13 +67,6 @@
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refDeletionValidator;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ProjectResource resource;
-  private final List<String> refsToDelete;
-  private String prefix;
-
-  public interface Factory {
-    DeleteRef create(ProjectResource r);
-  }
 
   @Inject
   DeleteRef(
@@ -79,135 +75,161 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refDeletionValidatorFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      @Assisted ProjectResource resource) {
+      Provider<InternalChangeQuery> queryProvider) {
     this.identifiedUser = identifiedUser;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
     this.queryProvider = queryProvider;
-    this.resource = resource;
-    this.refsToDelete = new ArrayList<>();
   }
 
-  public DeleteRef ref(String ref) {
-    this.refsToDelete.add(ref);
-    return this;
+  /**
+   * Deletes a single ref from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project containing the target ref.
+   * @param ref the ref to be deleted.
+   * @throws IOException
+   * @throws ResourceConflictException
+   */
+  public void deleteSingleRef(ProjectState projectState, String ref)
+      throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
+    deleteSingleRef(projectState, ref, null);
   }
 
-  public DeleteRef refs(List<String> refs) {
-    this.refsToDelete.addAll(refs);
-    return this;
-  }
-
-  public DeleteRef prefix(String prefix) {
-    this.prefix = prefix;
-    return this;
-  }
-
-  public void delete()
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    if (!refsToDelete.isEmpty()) {
-      try (Repository r = repoManager.openRepository(resource.getNameKey())) {
-        if (refsToDelete.size() == 1) {
-          deleteSingleRef(r);
-        } else {
-          deleteMultipleRefs(r);
-        }
-      }
-    }
-  }
-
-  private void deleteSingleRef(Repository r) throws IOException, ResourceConflictException {
-    String ref = refsToDelete.get(0);
+  /**
+   * Deletes a single ref from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project containing the target ref.
+   * @param ref the ref to be deleted.
+   * @param prefix the prefix of the ref.
+   * @throws IOException
+   * @throws ResourceConflictException
+   */
+  public void deleteSingleRef(ProjectState projectState, String ref, @Nullable String prefix)
+      throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
     if (prefix != null && !ref.startsWith(R_REFS)) {
       ref = prefix + ref;
     }
-    RefUpdate.Result result;
-    RefUpdate u = r.updateRef(ref);
-    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
-    u.setNewObjectId(ObjectId.zeroId());
-    u.setForceUpdate(true);
-    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
-    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-    for (; ; ) {
-      try {
-        result = u.delete();
-      } catch (LockFailedException e) {
-        result = RefUpdate.Result.LOCK_FAILURE;
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Cannot delete %s", ref);
-        throw e;
-      }
-      if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+
+    projectState.checkStatePermitsWrite();
+    permissionBackend
+        .currentUser()
+        .project(projectState.getNameKey())
+        .ref(ref)
+        .check(RefPermission.DELETE);
+
+    try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+      RefUpdate.Result result;
+      RefUpdate u = repository.updateRef(ref);
+      u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId());
+      u.setNewObjectId(ObjectId.zeroId());
+      u.setForceUpdate(true);
+      refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+      int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
+      for (; ; ) {
         try {
-          Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-        } catch (InterruptedException ie) {
-          // ignore
+          result = u.delete();
+        } catch (LockFailedException e) {
+          result = RefUpdate.Result.LOCK_FAILURE;
         }
-      } else {
-        break;
+        if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+          try {
+            Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
+          } catch (InterruptedException ie) {
+            // ignore
+          }
+        } else {
+          break;
+        }
       }
-    }
 
-    switch (result) {
-      case NEW:
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case FORCED:
-        referenceUpdated.fire(
-            resource.getNameKey(), u, ReceiveCommand.Type.DELETE, identifiedUser.get().state());
-        break;
+      switch (result) {
+        case NEW:
+        case NO_CHANGE:
+        case FAST_FORWARD:
+        case FORCED:
+          referenceUpdated.fire(
+              projectState.getNameKey(),
+              u,
+              ReceiveCommand.Type.DELETE,
+              identifiedUser.get().state());
+          break;
 
-      case REJECTED_CURRENT_BRANCH:
-        logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
-        throw new ResourceConflictException("cannot delete current branch");
+        case REJECTED_CURRENT_BRANCH:
+          logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
+          throw new ResourceConflictException("cannot delete current branch");
 
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
-        throw new ResourceConflictException("cannot delete: " + result.name());
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          logger.atSevere().log("Cannot delete %s: %s", ref, result.name());
+          throw new ResourceConflictException("cannot delete: " + result.name());
+      }
     }
   }
 
-  private void deleteMultipleRefs(Repository r)
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
-    batchUpdate.setAtomic(false);
-    List<String> refs =
-        prefix == null
-            ? refsToDelete
-            : refsToDelete
-                .stream()
-                .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
-                .collect(toList());
-    for (String ref : refs) {
-      batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
+  /**
+   * Deletes a set of refs from the repository.
+   *
+   * @param projectState the {@code ProjectState} of the project whose refs are to be deleted.
+   * @param refsToDelete the refs to be deleted.
+   * @param prefix the prefix of the refs.
+   * @throws OrmException
+   * @throws IOException
+   * @throws ResourceConflictException
+   * @throws PermissionBackendException
+   */
+  public void deleteMultipleRefs(
+      ProjectState projectState, ImmutableSet<String> refsToDelete, String prefix)
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException,
+          AuthException {
+    if (refsToDelete.isEmpty()) {
+      return;
     }
-    try (RevWalk rw = new RevWalk(r)) {
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+
+    if (refsToDelete.size() == 1) {
+      deleteSingleRef(projectState, Iterables.getOnlyElement(refsToDelete), prefix);
+      return;
     }
-    StringBuilder errorMessages = new StringBuilder();
-    for (ReceiveCommand command : batchUpdate.getCommands()) {
-      if (command.getResult() == Result.OK) {
-        postDeletion(resource, command);
-      } else {
-        appendAndLogErrorMessage(errorMessages, command);
+
+    try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+      BatchRefUpdate batchUpdate = repository.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAtomic(false);
+      ImmutableSet<String> refs =
+          prefix == null
+              ? refsToDelete
+              : refsToDelete
+                  .stream()
+                  .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
+                  .collect(toImmutableSet());
+      for (String ref : refs) {
+        batchUpdate.addCommand(createDeleteCommand(projectState, repository, ref));
       }
-    }
-    if (errorMessages.length() > 0) {
-      throw new ResourceConflictException(errorMessages.toString());
+      try (RevWalk rw = new RevWalk(repository)) {
+        batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      }
+      StringBuilder errorMessages = new StringBuilder();
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() == Result.OK) {
+          postDeletion(projectState.getNameKey(), command);
+        } else {
+          appendAndLogErrorMessage(errorMessages, command);
+        }
+      }
+      if (errorMessages.length() > 0) {
+        throw new ResourceConflictException(errorMessages.toString());
+      }
     }
   }
 
-  private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
+  private ReceiveCommand createDeleteCommand(
+      ProjectState projectState, Repository r, String refName)
       throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     Ref ref = r.getRefDatabase().getRef(refName);
     ReceiveCommand command;
@@ -227,7 +249,7 @@
       try {
         permissionBackend
             .currentUser()
-            .project(project.getNameKey())
+            .project(projectState.getNameKey())
             .ref(refName)
             .check(RefPermission.DELETE);
       } catch (AuthException denied) {
@@ -237,12 +259,12 @@
       }
     }
 
-    if (!project.getProjectState().statePermitsWrite()) {
+    if (!projectState.statePermitsWrite()) {
       command.setResult(Result.REJECTED_OTHER_REASON, "project state does not permit write");
     }
 
     if (!refName.startsWith(R_TAGS)) {
-      Branch.NameKey branchKey = new Branch.NameKey(project.getNameKey(), ref.getName());
+      Branch.NameKey branchKey = new Branch.NameKey(projectState.getNameKey(), ref.getName());
       if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
         command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
       }
@@ -252,7 +274,7 @@
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
     u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
     return command;
   }
 
@@ -281,7 +303,7 @@
     errorMessages.append("\n");
   }
 
-  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
-    referenceUpdated.fire(project.getNameKey(), cmd, identifiedUser.get().state());
+  private void postDeletion(Project.NameKey project, ReceiveCommand cmd) {
+    referenceUpdated.fire(project, cmd, identifiedUser.get().state());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index bd5f444..f7cce11 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -21,9 +21,7 @@
 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.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gwtorm.server.OrmException;
@@ -34,13 +32,11 @@
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, Input> {
 
-  private final PermissionBackend permissionBackend;
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteTag(PermissionBackend permissionBackend, DeleteRef.Factory deleteRefFactory) {
-    this.permissionBackend = permissionBackend;
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteTag(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
@@ -53,13 +49,7 @@
       throw new MethodNotAllowedException("not allowed to delete " + tag);
     }
 
-    permissionBackend
-        .currentUser()
-        .project(resource.getNameKey())
-        .ref(tag)
-        .check(RefPermission.DELETE);
-    resource.getProjectState().checkStatePermitsWrite();
-    deleteRefFactory.create(resource).ref(tag).delete();
+    deleteRef.deleteSingleRef(resource.getProjectState(), tag);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 83fa1ea..bf2c524 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -30,11 +31,11 @@
 
 @Singleton
 public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInput> {
-  private final DeleteRef.Factory deleteRefFactory;
+  private final DeleteRef deleteRef;
 
   @Inject
-  DeleteTags(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
+  DeleteTags(DeleteRef deleteRef) {
+    this.deleteRef = deleteRef;
   }
 
   @Override
@@ -43,7 +44,8 @@
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
-    deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
+    deleteRef.deleteMultipleRefs(
+        project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 53411b8..09f973b 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -14,34 +14,46 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.FileResource;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.kohsuke.args4j.Option;
 
 @Singleton
 public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final Provider<ListFiles> list;
   private final GitRepositoryManager repoManager;
 
   @Inject
   FilesInCommitCollection(
-      DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
+      DynamicMap<RestView<FileResource>> views,
+      Provider<ListFiles> list,
+      GitRepositoryManager repoManager) {
     this.views = views;
+    this.list = list;
     this.repoManager = repoManager;
   }
 
   @Override
   public RestView<CommitResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
+    return list.get();
   }
 
   @Override
@@ -57,4 +69,32 @@
   public DynamicMap<RestView<FileResource>> views() {
     return views;
   }
+
+  public static final class ListFiles implements RestReadView<CommitResource> {
+    @Option(name = "--parent", metaVar = "parent-number")
+    int parentNum;
+
+    private final FileInfoJson fileInfoJson;
+
+    @Inject
+    public ListFiles(FileInfoJson fileInfoJson) {
+      this.fileInfoJson = fileInfoJson;
+    }
+
+    @Override
+    public Object apply(CommitResource resource) throws PatchListNotAvailableException {
+      RevCommit commit = resource.getCommit();
+      PatchListKey key;
+
+      if (parentNum > 0) {
+        key =
+            PatchListKey.againstParentNum(
+                parentNum, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+      } else {
+        key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+      }
+
+      return fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index ea1620b..3b7f3e3 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.GarbageCollect.Input;
-import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -98,7 +98,7 @@
     WorkQueue.Task<Void> task = (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
 
     String location =
-        canonicalUrl.get() + "a/config/server/tasks/" + IdGenerator.format(task.getTaskId());
+        canonicalUrl.get() + "a/config/server/tasks/" + HexFormat.fromInt(task.getTaskId());
 
     return Response.accepted(location);
   }
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 6a50c2f..a6b9404 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
 import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
@@ -237,11 +238,15 @@
       }
     }
 
-    if (info.ownerOf.isEmpty()
-        && permissionBackend.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      // Special case: If the section list is empty, this project has no current
-      // access control information. Fall back to site administrators.
-      info.ownerOf.add(AccessSection.ALL);
+    if (info.ownerOf.isEmpty()) {
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+        // Special case: If the section list is empty, this project has no current
+        // access control information. Fall back to site administrators.
+        info.ownerOf.add(AccessSection.ALL);
+      } catch (AuthException e) {
+        // Do nothing.
+      }
     }
 
     if (config.getRevision() != null) {
@@ -266,6 +271,7 @@
                     || (canReadConfig
                         && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE))));
     info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
+    info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
     info.configVisible = canReadConfig || canWriteConfig;
 
     info.groups =
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index aafff9e..b3ad962 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +30,6 @@
 @Singleton
 public class GetConfig implements RestReadView<ProjectResource> {
   private final boolean serverEnableSignedPush;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -41,14 +39,12 @@
   @Inject
   public GetConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
@@ -62,7 +58,6 @@
         serverEnableSignedPush,
         resource.getProjectState(),
         resource.getUser(),
-        config,
         pluginConfigEntries,
         cfgFactory,
         allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index 24f32f6..1b2a523 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -16,57 +16,80 @@
 
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
-import com.google.common.io.ByteStreams;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.change.AllChangesIndexer;
-import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
+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;
 import java.util.concurrent.Future;
-import org.eclipse.jgit.util.io.NullOutputStream;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
 @Singleton
-public class Index implements RestModifyView<ProjectResource, ProjectInput> {
+public class Index implements RestModifyView<ProjectResource, IndexProjectInput> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
-  private final ChangeIndexer indexer;
+  private final ProjectIndexer indexer;
   private final ListeningExecutorService executor;
+  private final Provider<ListChildProjects> listChildProjectsProvider;
 
   @Inject
   Index(
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
-      ChangeIndexer indexer,
-      @IndexExecutor(BATCH) ListeningExecutorService executor) {
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
+      ProjectIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      Provider<ListChildProjects> listChildProjectsProvider) {
     this.indexer = indexer;
     this.executor = executor;
+    this.listChildProjectsProvider = listChildProjectsProvider;
   }
 
   @Override
-  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
-    Project.NameKey project = resource.getNameKey();
-    Task mpt =
-        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
-            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
-    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
-    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
-    // return value.
-    @SuppressWarnings("unused")
-    Future<Void> ignored =
-        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
-    return Response.accepted("Project " + project + " submitted for reindexing");
+  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input)
+      throws IOException, AuthException, OrmException, PermissionBackendException,
+          ResourceConflictException {
+    String response = "Project " + rsrc.getName() + " submitted for reindexing";
+
+    reindex(rsrc.getNameKey(), input.async);
+    if (Boolean.TRUE.equals(input.indexChildren)) {
+      ListChildProjects listChildProjects = listChildProjectsProvider.get();
+      listChildProjects.setRecursive(true);
+      for (ProjectInfo child : listChildProjects.apply(rsrc)) {
+        reindex(new Project.NameKey(child.name), input.async);
+      }
+
+      response += " (indexing children recursively)";
+    }
+    return Response.accepted(response);
+  }
+
+  private void reindex(Project.NameKey project, Boolean async) throws IOException {
+    if (Boolean.TRUE.equals(async)) {
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          executor.submit(
+              () -> {
+                try {
+                  indexer.index(project);
+                } catch (IOException e) {
+                  logger.atWarning().withCause(e).log("reindexing project %s failed", project);
+                }
+              });
+    } else {
+      indexer.index(project);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
new file mode 100644
index 0000000..b84f86c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 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.restapi.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class IndexChanges implements RestModifyView<ProjectResource, ProjectInput> {
+
+  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final ChangeIndexer indexer;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  IndexChanges(
+      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      ChangeIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.indexer = indexer;
+    this.executor = executor;
+  }
+
+  @Override
+  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
+    Project.NameKey project = resource.getNameKey();
+    Task mpt =
+        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
+    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
+    return Response.accepted("Project " + project + " submitted for reindexing");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index 6417967..a0d2528 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -45,7 +46,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
@@ -155,7 +155,7 @@
       throws IOException, ResourceNotFoundException, PermissionBackendException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
+      Collection<Ref> heads = db.getRefDatabase().getRefsByPrefix(Constants.R_HEADS);
       refs = new ArrayList<>(heads.size() + 3);
       refs.addAll(heads);
       refs.addAll(
@@ -185,9 +185,13 @@
         // showing the resolved value, show the name it references.
         //
         String target = ref.getTarget().getName();
-        if (!perm.ref(target).test(RefPermission.READ)) {
+
+        try {
+          perm.ref(target).check(RefPermission.READ);
+        } catch (AuthException e) {
           continue;
         }
+
         if (target.startsWith(Constants.R_HEADS)) {
           target = target.substring(Constants.R_HEADS.length());
         }
@@ -212,13 +216,16 @@
         continue;
       }
 
-      if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+      try {
+        perm.ref(ref.getName()).check(RefPermission.READ);
         branches.add(
             createBranchInfo(
                 perm.ref(ref.getName()), ref, rsrc.getProjectState(), rsrc.getUser(), targets));
+      } catch (AuthException e) {
+        // Do nothing.
       }
     }
-    Collections.sort(branches, new BranchComparator());
+    branches.sort(new BranchComparator());
     return branches;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 0f6b54f..3808a2f 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -99,13 +101,20 @@
 
   private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
+    if (!state.statePermitsRead()) {
+      return ImmutableList.of();
+    }
+
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(state.getNameKey());
     try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
       List<DashboardInfo> all = new ArrayList<>();
-      for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
-        if (perm.ref(ref.getName()).test(RefPermission.READ) && state.statePermitsRead()) {
+      for (Ref ref : git.getRefDatabase().getRefsByPrefix(REFS_DASHBOARDS)) {
+        try {
+          perm.ref(ref.getName()).check(RefPermission.READ);
           all.addAll(scanDashboards(state.getProject(), git, rw, ref, project, setDefault));
+        } catch (AuthException e) {
+          // Do nothing.
         }
       }
       return all;
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 72a0788..8c1b0b3 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
 import com.google.gerrit.server.ioutil.StringUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
-import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index e79fdca..f59e984 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
@@ -39,8 +40,6 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -135,14 +134,7 @@
       }
     }
 
-    Collections.sort(
-        tags,
-        new Comparator<TagInfo>() {
-          @Override
-          public int compare(TagInfo a, TagInfo b) {
-            return a.ref.compareTo(b.ref);
-          }
-        });
+    tags.sort(comparing(t -> t.ref));
 
     return new RefFilter<TagInfo>(Constants.R_TAGS)
         .start(start)
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 337084c..0497787 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.restapi.change.CherryPickCommit;
 
 public class Module extends RestApiModule {
   @Override
@@ -40,6 +41,7 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
 
+    create(PROJECT_KIND).to(CreateProject.class);
     put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND).to(GetProject.class);
     get(PROJECT_KIND, "description").to(GetDescription.class);
@@ -52,6 +54,8 @@
     post(PROJECT_KIND, "check.access").to(CheckAccess.class);
     get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
+    post(PROJECT_KIND, "check").to(Check.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
@@ -66,13 +70,14 @@
     get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
     post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
+    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
+    create(BRANCH_KIND).to(CreateBranch.class);
     put(BRANCH_KIND).to(PutBranch.class);
     get(BRANCH_KIND).to(GetBranch.class);
     delete(BRANCH_KIND).to(DeleteBranch.class);
     post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
-    factory(CreateBranch.Factory.class);
     get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
     factory(RefValidationHelper.Factory.class);
     get(BRANCH_KIND, "reflog").to(GetReflog.class);
@@ -85,22 +90,22 @@
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
+    create(TAG_KIND).to(CreateTag.class);
     get(TAG_KIND).to(GetTag.class);
     put(TAG_KIND).to(PutTag.class);
     delete(TAG_KIND).to(DeleteTag.class);
     post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
-    factory(CreateTag.Factory.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
+    create(DASHBOARD_KIND).to(CreateDashboard.class);
     get(DASHBOARD_KIND).to(GetDashboard.class);
     put(DASHBOARD_KIND).to(SetDashboard.class);
     delete(DASHBOARD_KIND).to(DeleteDashboard.class);
-    factory(CreateProject.Factory.class);
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
 
-    factory(DeleteRef.Factory.class);
     factory(ProjectNode.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 3af8424..dafc2fa 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -46,27 +45,23 @@
 
 @Singleton
 public class ProjectsCollection
-    implements RestCollection<TopLevelResource, ProjectResource>,
-        AcceptsCreate<TopLevelResource>,
-        NeedsParams {
+    implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final Provider<QueryProjects> queryProjects;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
-  private final CreateProject.Factory createProjectFactory;
 
   private boolean hasQuery;
 
   @Inject
-  ProjectsCollection(
+  public ProjectsCollection(
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
       Provider<QueryProjects> queryProjects,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
@@ -74,7 +69,6 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.user = user;
-    this.createProjectFactory = factory;
   }
 
   @Override
@@ -179,9 +173,4 @@
   public DynamicMap<RestView<ProjectResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateProject create(TopLevelResource parent, IdString name) {
-    return createProjectFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index db596e6..76ea0c9 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -71,7 +70,6 @@
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -86,7 +84,6 @@
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -98,7 +95,6 @@
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
@@ -168,12 +164,11 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfig);
+      ProjectState state = projectStateFactory.create(ProjectConfig.read(md));
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
           user.get(),
-          config,
           pluginConfigEntries,
           cfgFactory,
           allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 422c749..c5c8cf0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -115,7 +115,7 @@
           }
           p.add(r);
         }
-        accessSection.getPermissions().add(p);
+        accessSection.addPermission(p);
       }
       sections.add(accessSection);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
index 891978b..2804b7c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index ba91e0e..ef292e5 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -17,7 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -49,7 +49,7 @@
   private final PermissionBackend permissionBackend;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
-  private boolean inherited;
+  boolean inherited;
 
   @Inject
   SetDefaultDashboard(
@@ -128,25 +128,4 @@
           String.format("invalid project.config: %s", e.getMessage()));
     }
   }
-
-  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboardInput> {
-    private final Provider<SetDefaultDashboard> setDefault;
-
-    @Option(name = "--inherited", usage = "set dashboard inherited by children")
-    private boolean inherited;
-
-    @Inject
-    CreateDefault(Provider<SetDefaultDashboard> setDefault) {
-      this.setDefault = setDefault;
-    }
-
-    @Override
-    public Response<DashboardInfo> apply(ProjectResource resource, SetDashboardInput input)
-        throws RestApiException, IOException, PermissionBackendException {
-      SetDefaultDashboard set = setDefault.get();
-      set.inherited = inherited;
-      return set.apply(
-          DashboardResource.projectDefault(resource.getProjectState(), resource.getUser()), input);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 6710f6c..b38619b 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -30,10 +30,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
@@ -43,6 +45,7 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class SetParent implements RestModifyView<ProjectResource, ParentInput> {
@@ -51,6 +54,7 @@
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
+  private final boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
@@ -58,12 +62,15 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.Server updateFactory,
       AllProjectsName allProjects,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      @GerritServerConfig Config config) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
+    this.allowProjectOwnersToChangeParent =
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
   }
 
   @Override
@@ -114,7 +121,11 @@
       throws AuthException, ResourceConflictException, UnprocessableEntityException,
           PermissionBackendException, BadRequestException {
     if (checkIfAdmin) {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      if (allowProjectOwnersToChangeParent) {
+        permissionBackend.user(user).project(project).check(ProjectPermission.WRITE_CONFIG);
+      } else {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
     }
 
     if (project.equals(allUsers) && !allProjects.get().equals(newParent)) {
diff --git a/java/com/google/gerrit/server/restapi/project/TagsCollection.java b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
index fdace77..a129bda 100644
--- a/java/com/google/gerrit/server/restapi/project/TagsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -30,20 +29,14 @@
 import java.io.IOException;
 
 @Singleton
-public class TagsCollection
-    implements ChildCollection<ProjectResource, TagResource>, AcceptsCreate<ProjectResource> {
+public class TagsCollection implements ChildCollection<ProjectResource, TagResource> {
   private final DynamicMap<RestView<TagResource>> views;
   private final Provider<ListTags> list;
-  private final CreateTag.Factory createTagFactory;
 
   @Inject
-  public TagsCollection(
-      DynamicMap<RestView<TagResource>> views,
-      Provider<ListTags> list,
-      CreateTag.Factory createTagFactory) {
+  public TagsCollection(DynamicMap<RestView<TagResource>> views, Provider<ListTags> list) {
     this.views = views;
     this.list = list;
-    this.createTagFactory = createTagFactory;
   }
 
   @Override
@@ -62,9 +55,4 @@
   public DynamicMap<RestView<TagResource>> views() {
     return views;
   }
-
-  @Override
-  public CreateTag create(ProjectResource resource, IdString name) {
-    return createTagFactory.create(name.get());
-  }
 }
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
new file mode 100644
index 0000000..b9ddbc6
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2018 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.rules;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Rule to require an approval from a user that did not upload the current patch set or block
+ * submission.
+ */
+@Singleton
+public class IgnoreSelfApprovalRule implements SubmitRule {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String E_UNABLE_TO_FETCH_UPLOADER = "Unable to fetch uploader";
+  private static final String E_UNABLE_TO_FETCH_LABELS =
+      "Unable to fetch labels and approvals for the change";
+
+  public static class Module extends FactoryModule {
+    @Override
+    public void configure() {
+      bind(SubmitRule.class)
+          .annotatedWith(Exports.named("IgnoreSelfApprovalRule"))
+          .to(IgnoreSelfApprovalRule.class);
+    }
+  }
+
+  @Inject
+  IgnoreSelfApprovalRule() {}
+
+  @Override
+  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+    List<LabelType> labelTypes;
+    List<PatchSetApproval> approvals;
+    try {
+      labelTypes = cd.getLabelTypes().getLabelTypes();
+      approvals = cd.currentApprovals();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
+      return singletonRuleError(E_UNABLE_TO_FETCH_LABELS);
+    }
+
+    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(l -> l.ignoreSelfApproval());
+    if (!shouldIgnoreSelfApproval) {
+      // Shortcut to avoid further processing if no label should ignore uploader approvals
+      return ImmutableList.of();
+    }
+
+    Account.Id uploader;
+    try {
+      uploader = cd.currentPatchSet().getUploader();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
+      return singletonRuleError(E_UNABLE_TO_FETCH_UPLOADER);
+    }
+
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.status = SubmitRecord.Status.OK;
+    submitRecord.labels = new ArrayList<>(labelTypes.size());
+    submitRecord.requirements = new ArrayList<>();
+
+    for (LabelType t : labelTypes) {
+      if (!t.ignoreSelfApproval()) {
+        // The default rules are enough in this case.
+        continue;
+      }
+
+      LabelFunction labelFunction = t.getFunction();
+      if (labelFunction == null) {
+        continue;
+      }
+
+      Collection<PatchSetApproval> allApprovalsForLabel = filterApprovalsByLabel(approvals, t);
+      SubmitRecord.Label allApprovalsCheckResult = labelFunction.check(t, allApprovalsForLabel);
+      SubmitRecord.Label ignoreSelfApprovalCheckResult =
+          labelFunction.check(t, filterOutPositiveApprovalsOfUser(allApprovalsForLabel, uploader));
+
+      if (labelCheckPassed(allApprovalsCheckResult)
+          && !labelCheckPassed(ignoreSelfApprovalCheckResult)) {
+        // The label has a valid approval from the uploader and no other valid approval. Set the
+        // label
+        // to NOT_READY and indicate the need for non-uploader approval as requirement.
+        submitRecord.labels.add(ignoreSelfApprovalCheckResult);
+        submitRecord.status = SubmitRecord.Status.NOT_READY;
+        // Add an additional requirement to be more descriptive on why the label counts as not
+        // approved.
+        submitRecord.requirements.add(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+      }
+    }
+
+    if (submitRecord.labels.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    return ImmutableList.of(submitRecord);
+  }
+
+  private static boolean labelCheckPassed(SubmitRecord.Label label) {
+    switch (label.status) {
+      case OK:
+      case MAY:
+        return true;
+
+      case NEED:
+      case REJECT:
+      case IMPOSSIBLE:
+        return false;
+    }
+    return false;
+  }
+
+  private static Collection<SubmitRecord> singletonRuleError(String reason) {
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.errorMessage = reason;
+    submitRecord.status = SubmitRecord.Status.RULE_ERROR;
+    return ImmutableList.of(submitRecord);
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
+      Collection<PatchSetApproval> approvals, Account.Id user) {
+    return approvals
+        .stream()
+        .filter(input -> input.getValue() < 0 || !input.getAccountId().equals(user))
+        .collect(toImmutableList());
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterApprovalsByLabel(
+      Collection<PatchSetApproval> approvals, LabelType t) {
+    return approvals
+        .stream()
+        .filter(input -> input.getLabelId().get().equals(t.getLabelId().get()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 083898b..412e0f9 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -176,6 +177,7 @@
     private final int reductionLimit;
     private final int compileLimit;
     private final PatchSetUtil patchsetUtil;
+    private Emails emails;
 
     @Inject
     Args(
@@ -187,7 +189,8 @@
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config,
-        PatchSetUtil patchsetUtil) {
+        PatchSetUtil patchsetUtil,
+        Emails emails) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
@@ -196,6 +199,7 @@
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
       this.patchsetUtil = patchsetUtil;
+      this.emails = emails;
 
       int limit = config.getInt("rules", null, "reductionLimit", 100000);
       reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
@@ -247,5 +251,9 @@
     public PatchSetUtil getPatchsetUtil() {
       return patchsetUtil;
     }
+
+    public Emails getEmails() {
+      return emails;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 5e2cfcc..e61fc90 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -18,7 +18,10 @@
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -58,6 +61,15 @@
  */
 public class PrologRuleEvaluator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  /**
+   * List of characters to allow in the label name, when an invalid name is used. Dash is allowed as
+   * it can't be the first character: we use a prefix.
+   */
+  private static final CharMatcher VALID_LABEL_MATCHER =
+      CharMatcher.is('-')
+          .or(CharMatcher.inRange('a', 'z'))
+          .or(CharMatcher.inRange('A', 'Z'))
+          .or(CharMatcher.inRange('0', '9'));
 
   public interface Factory {
     /** Returns a new {@link PrologRuleEvaluator} with the specified options */
@@ -231,7 +243,7 @@
         SubmitRecord.Label lbl = new SubmitRecord.Label();
         rec.labels.add(lbl);
 
-        lbl.label = state.arg(0).name();
+        lbl.label = checkLabelName(state.arg(0).name());
         Term status = state.arg(1);
 
         try {
@@ -280,6 +292,20 @@
     return out;
   }
 
+  @VisibleForTesting
+  static String checkLabelName(String name) {
+    try {
+      return LabelType.checkName(name);
+    } catch (IllegalArgumentException e) {
+      String newName = "Invalid-Prolog-Rules-Label-Name-" + sanitizeLabelName(name);
+      return LabelType.checkName(newName.replace("--", "-"));
+    }
+  }
+
+  private static String sanitizeLabelName(String name) {
+    return VALID_LABEL_MATCHER.retainFrom(name);
+  }
+
   private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
     return ruleError(
         String.format(
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 6770732..8b9cfe3 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
@@ -34,8 +33,6 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -47,6 +44,7 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public final class StoredValues {
   public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
@@ -74,32 +72,16 @@
     }
   }
 
-  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
-      new StoredValue<PatchSetInfo>() {
+  public static final StoredValue<RevCommit> COMMIT =
+      new StoredValue<RevCommit>() {
         @Override
-        public PatchSetInfo createValue(Prolog engine) {
-          Change change = getChange(engine);
-          PatchSet ps = getPatchSet(engine);
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
-          try {
-            return patchInfoFactory.get(change.getProject(), ps);
-          } catch (PatchSetInfoNotAvailableException e) {
-            throw new SystemException(e.getMessage());
-          }
-        }
-      };
-
-  public static final StoredValue<String> COMMIT_MESSAGE =
-      new StoredValue<String>() {
-        @Override
-        public String createValue(Prolog engine) {
+        public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
           PatchSet ps = getPatchSet(engine);
           PrologEnvironment env = (PrologEnvironment) engine.control;
           PatchSetUtil patchSetUtil = env.getArgs().getPatchsetUtil();
           try {
-            return patchSetUtil.getFullCommitMessage(change.getProject(), ps);
+            return patchSetUtil.getRevCommit(change.getProject(), ps);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index a91d5e6..348f88c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -52,6 +53,8 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -66,28 +69,29 @@
 
 /** Creates the {@code All-Projects} repository and initial ACLs. */
 public class AllProjectsCreator {
-  private final GitRepositoryManager mgr;
+  private final GitRepositoryManager repositoryManager;
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
   private final NotesMigration notesMigration;
-  private String message;
-  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
-
-  @Nullable private GroupReference admin;
-
-  @Nullable private GroupReference batch;
   private final GroupReference anonymous;
   private final GroupReference registered;
   private final GroupReference owners;
 
+  @Nullable private GroupReference admin;
+  @Nullable private GroupReference batch;
+  private String message;
+  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
+  private LabelType codeReviewLabel;
+  private List<LabelType> additionalLabelType;
+
   @Inject
   AllProjectsCreator(
-      GitRepositoryManager mgr,
+      GitRepositoryManager repositoryManager,
       AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser,
-      NotesMigration notesMigration) {
-    this.mgr = mgr;
+      NotesMigration notesMigration,
+      SystemGroupBackend systemGroupBackend) {
+    this.repositoryManager = repositoryManager;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
     this.notesMigration = notesMigration;
@@ -95,6 +99,8 @@
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
     this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
+    this.codeReviewLabel = getDefaultCodeReviewLabel();
+    this.additionalLabelType = new ArrayList<>();
   }
 
   /** If called, grant default permissions to this admin group */
@@ -114,19 +120,35 @@
     return this;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
     checkArgument(id > 0, "id must be positive: %s", id);
     firstChangeId = id;
     return this;
   }
 
+  /** If called, the provided "Code-Review" label will be used rather than the default. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllProjectsCreator setCodeReviewLabel(LabelType labelType) {
+    checkArgument(
+        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+    this.codeReviewLabel = labelType;
+    return this;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllProjectsCreator addAdditionalLabel(LabelType labelType) {
+    additionalLabelType.add(labelType);
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allProjectsName)) {
+    try (Repository git = repositoryManager.openRepository(allProjectsName)) {
       initAllProjects(git);
     } catch (RepositoryNotFoundException notFound) {
       // A repository may be missing if this project existed only to store
       // inheritable permissions. For example 'All-Projects'.
-      try (Repository git = mgr.createRepository(allProjectsName)) {
+      try (Repository git = repositoryManager.createRepository(allProjectsName)) {
         initAllProjects(git);
         RefUpdate u = git.updateRef(Constants.HEAD);
         u.link(RefNames.REFS_CONFIG);
@@ -179,10 +201,10 @@
         stream.add(rule(config, batch));
       }
 
-      LabelType cr = initCodeReviewLabel(config);
-      grant(config, heads, cr, -1, 1, registered);
+      initLabels(config);
+      grant(config, heads, codeReviewLabel, -1, 1, registered);
 
-      grant(config, heads, cr, -2, 2, admin, owners);
+      grant(config, heads, codeReviewLabel, -2, 2, admin, owners);
       grant(config, heads, Permission.CREATE, admin, owners);
       grant(config, heads, Permission.PUSH, admin, owners);
       grant(config, heads, Permission.SUBMIT, admin, owners);
@@ -199,7 +221,7 @@
 
       meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
       grant(config, meta, Permission.READ, admin, owners);
-      grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, codeReviewLabel, -2, 2, admin, owners);
       grant(config, meta, Permission.CREATE, admin, owners);
       grant(config, meta, Permission.PUSH, admin, owners);
       grant(config, meta, Permission.SUBMIT, admin, owners);
@@ -210,7 +232,8 @@
     }
   }
 
-  public static LabelType initCodeReviewLabel(ProjectConfig c) {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static LabelType getDefaultCodeReviewLabel() {
     LabelType type =
         new LabelType(
             "Code-Review",
@@ -222,10 +245,14 @@
                 new LabelValue((short) -2, "This shall not be merged")));
     type.setCopyMinScore(true);
     type.setCopyAllScoresOnTrivialRebase(true);
-    c.getLabelSections().put(type.getName(), type);
     return type;
   }
 
+  private void initLabels(ProjectConfig projectConfig) {
+    projectConfig.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
+    additionalLabelType.forEach(t -> projectConfig.getLabelSections().put(t.getName(), t));
+  }
+
   private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
     if (notesMigration.readChangeSequence()
         && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90002f6..3779d0d 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.server.schema.AllProjectsCreator.getDefaultCodeReviewLabel;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
@@ -26,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -50,6 +53,7 @@
   private final GroupReference registered;
 
   @Nullable private GroupReference admin;
+  private LabelType codeReviewLabel;
 
   @Inject
   AllUsersCreator(
@@ -61,6 +65,7 @@
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.codeReviewLabel = getDefaultCodeReviewLabel();
   }
 
   /**
@@ -72,6 +77,15 @@
     return this;
   }
 
+  /** If called, the provided "Code-Review" label will be used rather than the default. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllUsersCreator setCodeReviewLabel(LabelType labelType) {
+    checkArgument(
+        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+    this.codeReviewLabel = labelType;
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
     try (Repository git = mgr.openRepository(allUsersName)) {
       initAllUsers(git);
@@ -100,11 +114,14 @@
       AccessSection users =
           config.getAccessSection(
               RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
-      LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
+
+      // Initialize "Code-Review" label.
+      config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
+
       grant(config, users, Permission.READ, false, true, registered);
       grant(config, users, Permission.PUSH, false, true, registered);
       grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, cr, -2, 2, true, registered);
+      grant(config, users, codeReviewLabel, -2, 2, true, registered);
 
       if (admin != null) {
         AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
diff --git a/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 98acc64..d4cfaa6 100644
--- a/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -44,6 +44,8 @@
 /** Provides access to the DataSource. */
 @Singleton
 public class DataSourceProvider implements Provider<DataSource>, LifecycleListener {
+  private static final String DATABASE_KEY = "database";
+
   private final Config cfg;
   private final MetricMaker metrics;
   private final Context ctx;
@@ -93,7 +95,7 @@
   }
 
   private DataSource open(Config cfg, Context context, DataSourceType dst) {
-    ConfigSection dbs = new ConfigSection(cfg, "database");
+    ConfigSection dbs = new ConfigSection(cfg, DATABASE_KEY);
     String driver = dbs.optional("driver");
     if (Strings.isNullOrEmpty(driver)) {
       driver = dst.getDriver();
@@ -112,39 +114,41 @@
     if (context == Context.SINGLE_USER) {
       usePool = false;
     } else {
-      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
+      usePool = cfg.getBoolean(DATABASE_KEY, "connectionpool", dst.usePool());
     }
 
     if (usePool) {
-      final BasicDataSource ds = new BasicDataSource();
-      ds.setDriverClassName(driver);
-      ds.setUrl(url);
+      final BasicDataSource lds = new BasicDataSource();
+      lds.setDriverClassName(driver);
+      lds.setUrl(url);
       if (username != null && !username.isEmpty()) {
-        ds.setUsername(username);
+        lds.setUsername(username);
       }
       if (password != null && !password.isEmpty()) {
-        ds.setPassword(password);
+        lds.setPassword(password);
       }
       int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
-      ds.setMaxActive(poolLimit);
-      ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
-      ds.setMaxWait(
+      lds.setMaxActive(poolLimit);
+      lds.setMinIdle(cfg.getInt(DATABASE_KEY, "poolminidle", 4));
+      lds.setMaxIdle(cfg.getInt(DATABASE_KEY, "poolmaxidle", Math.min(poolLimit, 16)));
+      lds.setMaxWait(
           ConfigUtil.getTimeUnit(
               cfg,
-              "database",
+              DATABASE_KEY,
               null,
               "poolmaxwait",
               MILLISECONDS.convert(30, SECONDS),
               MILLISECONDS));
-      ds.setInitialSize(ds.getMinIdle());
+      lds.setInitialSize(lds.getMinIdle());
       long evictIdleTimeMs = 1000L * 60;
-      ds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
-      ds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
-      ds.setValidationQuery(dst.getValidationQuery());
-      ds.setValidationQueryTimeout(5);
-      exportPoolMetrics(ds);
-      return intercept(interceptor, ds);
+      lds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+      lds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
+      lds.setTestOnBorrow(true);
+      lds.setTestOnReturn(true);
+      lds.setValidationQuery(dst.getValidationQuery());
+      lds.setValidationQueryTimeout(5);
+      exportPoolMetrics(lds);
+      return intercept(interceptor, lds);
     }
     // Don't use the connection pool.
     //
diff --git a/java/com/google/gerrit/server/schema/GroupBundle.java b/java/com/google/gerrit/server/schema/GroupBundle.java
index 58d3435..a15cedc 100644
--- a/java/com/google/gerrit/server/schema/GroupBundle.java
+++ b/java/com/google/gerrit/server/schema/GroupBundle.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gerrit.server.group.InternalGroup;
@@ -118,9 +119,10 @@
       this.auditLogReader = auditLogReader;
     }
 
-    public GroupBundle fromNoteDb(Repository repo, AccountGroup.UUID uuid)
+    public GroupBundle fromNoteDb(
+        Project.NameKey projectName, Repository repo, AccountGroup.UUID uuid)
         throws ConfigInvalidException, IOException {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, uuid);
+      GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repo, uuid);
       InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
       AccountGroup.Id groupId = internalGroup.getId();
 
diff --git a/java/com/google/gerrit/server/schema/GroupRebuilder.java b/java/com/google/gerrit/server/schema/GroupRebuilder.java
index f98c948..be8dcff 100644
--- a/java/com/google/gerrit/server/schema/GroupRebuilder.java
+++ b/java/com/google/gerrit/server/schema/GroupRebuilder.java
@@ -81,7 +81,7 @@
             .setNameKey(group.getNameKey())
             .setGroupUUID(group.getGroupUUID())
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsers, allUsersRepo, groupCreation);
     groupConfig.setAllowSaveEmptyName();
 
     InternalGroupUpdate.Builder updateBuilder =
diff --git a/java/com/google/gerrit/server/schema/JdbcUtil.java b/java/com/google/gerrit/server/schema/JdbcUtil.java
index 2624923..dddf23a 100644
--- a/java/com/google/gerrit/server/schema/JdbcUtil.java
+++ b/java/com/google/gerrit/server/schema/JdbcUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.server.UsedAt;
+
 public class JdbcUtil {
 
   public static String hostname(String hostname) {
@@ -26,6 +28,7 @@
     return hostname;
   }
 
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
   public static String port(String port) {
     if (port != null && !port.isEmpty()) {
       return ":" + port;
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
index d650dc7..743019d 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -214,12 +214,14 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), serverId);
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig =
+        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
     GroupNameNotes groupNameNotes =
-        GroupNameNotes.forNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+        GroupNameNotes.forNewGroup(
+            allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
     commit(allUsersRepo, groupConfig, groupNameNotes);
 
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
index e8a59d0..61e9c92 100644
--- a/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.UsedAt;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
@@ -35,7 +36,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_168> C = Schema_168.class;
+  public static final Class<Schema_169> C = Schema_169.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -49,6 +50,7 @@
     this.versionNbr = guessVersion(getClass());
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public static int guessVersion(Class<?> c) {
     String n = c.getName();
     n = n.substring(n.lastIndexOf('_') + 1);
diff --git a/java/com/google/gerrit/server/schema/Schema_108.java b/java/com/google/gerrit/server/schema/Schema_108.java
index dc88f8d..4e62460 100644
--- a/java/com/google/gerrit/server/schema/Schema_108.java
+++ b/java/com/google/gerrit/server/schema/Schema_108.java
@@ -89,7 +89,7 @@
     rw.sort(RevSort.REVERSE, true);
 
     RefDatabase refdb = repo.getRefDatabase();
-    for (Ref ref : refdb.getRefs(Constants.R_HEADS).values()) {
+    for (Ref ref : refdb.getRefsByPrefix(Constants.R_HEADS)) {
       RevCommit c = maybeParseCommit(rw, ref.getObjectId(), ui);
       if (c != null) {
         rw.markUninteresting(c);
@@ -100,7 +100,7 @@
         MultimapBuilder.hashKeys().arrayListValues().build();
     ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha =
         MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Ref ref : refdb.getRefs(RefNames.REFS_CHANGES).values()) {
+    for (Ref ref : refdb.getRefsByPrefix(RefNames.REFS_CHANGES)) {
       ObjectId id = ref.getObjectId();
       if (ref.getObjectId() == null) {
         continue;
diff --git a/java/com/google/gerrit/server/schema/Schema_139.java b/java/com/google/gerrit/server/schema/Schema_139.java
index 1d90305..f362a7d 100644
--- a/java/com/google/gerrit/server/schema/Schema_139.java
+++ b/java/com/google/gerrit/server/schema/Schema_139.java
@@ -148,7 +148,7 @@
           md.getCommitBuilder().setCommitter(serverUser);
           md.setMessage(MSG);
 
-          AccountConfig accountConfig = new AccountConfig(e.getKey(), git);
+          AccountConfig accountConfig = new AccountConfig(e.getKey(), allUsersName, git);
           accountConfig.load(md);
           accountConfig.setAccountUpdate(
               InternalAccountUpdate.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_144.java b/java/com/google/gerrit/server/schema/Schema_144.java
index f1c9745..bb0cbca 100644
--- a/java/com/google/gerrit/server/schema/Schema_144.java
+++ b/java/com/google/gerrit/server/schema/Schema_144.java
@@ -80,7 +80,7 @@
 
     try {
       try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersName, repo);
         extIdNotes.upsert(toAdd);
         try (MetaDataUpdate metaDataUpdate =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
diff --git a/java/com/google/gerrit/server/schema/Schema_147.java b/java/com/google/gerrit/server/schema/Schema_147.java
index fd85463..c507fa6 100644
--- a/java/com/google/gerrit/server/schema/Schema_147.java
+++ b/java/com/google/gerrit/server/schema/Schema_147.java
@@ -56,8 +56,7 @@
       Set<Account.Id> accountIdsFromReviewDb = scanAccounts(db);
       Set<Account.Id> accountIdsFromUserBranches =
           repo.getRefDatabase()
-              .getRefs(RefNames.REFS_USERS)
-              .values()
+              .getRefsByPrefix(RefNames.REFS_USERS)
               .stream()
               .map(r -> Account.Id.fromRef(r.getName()))
               .filter(Objects::nonNull)
diff --git a/java/com/google/gerrit/server/schema/Schema_148.java b/java/com/google/gerrit/server/schema/Schema_148.java
index 98d4909..9433da8 100644
--- a/java/com/google/gerrit/server/schema/Schema_148.java
+++ b/java/com/google/gerrit/server/schema/Schema_148.java
@@ -55,7 +55,7 @@
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersName, repo);
       for (ExternalId extId : extIdNotes.all()) {
         if (needsUpdate(extId)) {
           extIdNotes.upsert(extId);
diff --git a/java/com/google/gerrit/server/schema/Schema_154.java b/java/com/google/gerrit/server/schema/Schema_154.java
index 8dfd356..fab1693 100644
--- a/java/com/google/gerrit/server/schema/Schema_154.java
+++ b/java/com/google/gerrit/server/schema/Schema_154.java
@@ -139,7 +139,10 @@
     PersonIdent ident = serverIdent.get();
     md.getCommitBuilder().setAuthor(ident);
     md.getCommitBuilder().setCommitter(ident);
-    new AccountConfig(account.getId(), allUsersRepo).load().setAccount(account).commit(md);
+    new AccountConfig(account.getId(), allUsersName, allUsersRepo)
+        .load()
+        .setAccount(account)
+        .commit(md);
   }
 
   @FunctionalInterface
diff --git a/java/com/google/gerrit/server/schema/Schema_159.java b/java/com/google/gerrit/server/schema/Schema_159.java
index f97453e..d6e37d7 100644
--- a/java/com/google/gerrit/server/schema/Schema_159.java
+++ b/java/com/google/gerrit/server/schema/Schema_159.java
@@ -23,7 +23,7 @@
 /** Migrate draft changes to private or wip changes. */
 public class Schema_159 extends SchemaVersion {
 
-  private static enum DraftWorkflowMigrationStrategy {
+  private enum DraftWorkflowMigrationStrategy {
     PRIVATE,
     WORK_IN_PROGRESS
   }
diff --git a/java/com/google/gerrit/server/schema/Schema_160.java b/java/com/google/gerrit/server/schema/Schema_160.java
index 99ca465..eb8b70f 100644
--- a/java/com/google/gerrit/server/schema/Schema_160.java
+++ b/java/com/google/gerrit/server/schema/Schema_160.java
@@ -110,7 +110,7 @@
     md.getCommitBuilder().setAuthor(ident);
     md.getCommitBuilder().setCommitter(ident);
     Prefs prefs = new Prefs(ref);
-    prefs.load(repo);
+    prefs.load(allUsersName, repo);
     prefs.removeMyDrafts();
     prefs.commit(md);
     if (prefs.dirty()) {
diff --git a/java/com/google/gerrit/server/schema/Schema_161.java b/java/com/google/gerrit/server/schema/Schema_161.java
index febe80e..3077720 100644
--- a/java/com/google/gerrit/server/schema/Schema_161.java
+++ b/java/com/google/gerrit/server/schema/Schema_161.java
@@ -60,7 +60,7 @@
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
       bru.setAllowNonFastForwards(true);
 
-      for (Ref ref : git.getRefDatabase().getRefs(RefNames.REFS_STARRED_CHANGES).values()) {
+      for (Ref ref : git.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
         StarRef starRef = StarredChangesUtil.readLabels(git, ref.getName());
 
         Set<Integer> mutedPatchSets =
diff --git a/java/com/google/gerrit/server/schema/Schema_167.java b/java/com/google/gerrit/server/schema/Schema_167.java
index ba93751..a5066cc 100644
--- a/java/com/google/gerrit/server/schema/Schema_167.java
+++ b/java/com/google/gerrit/server/schema/Schema_167.java
@@ -150,7 +150,8 @@
       ReviewDb db, Repository allUsersRepo, Config gerritConfig, SitePaths sitePaths)
       throws IOException, ConfigInvalidException {
     String serverId = new GerritServerIdProvider(gerritConfig, sitePaths).get();
-    SimpleInMemoryAccountCache accountCache = new SimpleInMemoryAccountCache(allUsersRepo);
+    SimpleInMemoryAccountCache accountCache =
+        new SimpleInMemoryAccountCache(allUsersName, allUsersRepo);
     SimpleInMemoryGroupCache groupCache = new SimpleInMemoryGroupCache(db);
     return AuditLogFormatter.create(
         accountCache::get,
@@ -178,10 +179,12 @@
   // The regular account cache isn't available during init. -> Use a simple replacement which tries
   // to load every account only once from disk.
   private static class SimpleInMemoryAccountCache {
+    private final AllUsersName allUsersName;
     private final Repository allUsersRepo;
     private Map<Account.Id, Optional<Account>> accounts = new HashMap<>();
 
-    public SimpleInMemoryAccountCache(Repository allUsersRepo) {
+    public SimpleInMemoryAccountCache(AllUsersName allUsersName, Repository allUsersRepo) {
+      this.allUsersName = allUsersName;
       this.allUsersRepo = allUsersRepo;
     }
 
@@ -192,7 +195,8 @@
 
     private Optional<Account> load(Account.Id accountId) {
       try {
-        AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+        AccountConfig accountConfig =
+            new AccountConfig(accountId, allUsersName, allUsersRepo).load();
         return accountConfig.getLoadedAccount();
       } catch (IOException | ConfigInvalidException ignored) {
         logger.atWarning().withCause(ignored).log(
diff --git a/java/com/google/gerrit/server/schema/Schema_169.java b/java/com/google/gerrit/server/schema/Schema_169.java
new file mode 100644
index 0000000..2779d47
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_169.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 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.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.CommentJsonMigrator;
+import com.google.gerrit.server.notedb.CommentJsonMigrator.ProjectMigrationResult;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.SortedSet;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+
+/** Migrate NoteDb inline comments to JSON format. */
+public class Schema_169 extends SchemaVersion {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final CommentJsonMigrator migrator;
+  private final GitRepositoryManager repoManager;
+  private final NotesMigration notesMigration;
+
+  @Inject
+  Schema_169(
+      Provider<Schema_168> prior,
+      CommentJsonMigrator migrator,
+      GitRepositoryManager repoManager,
+      @GerritServerConfig Config config) {
+    super(prior);
+    this.migrator = migrator;
+    this.repoManager = repoManager;
+    this.notesMigration = MutableNotesMigration.fromConfig(config);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    migrateData(ui);
+  }
+
+  @VisibleForTesting
+  protected void migrateData(UpdateUI ui) throws OrmException {
+    //  If the migration hasn't started, no need to look for non-JSON
+    if (!notesMigration.commitChangeWrites()) {
+      return;
+    }
+
+    boolean ok = true;
+    ProgressMonitor pm = new TextProgressMonitor();
+    SortedSet<Project.NameKey> projects = repoManager.list();
+    pm.beginTask("Migrating projects", projects.size());
+    int skipped = 0;
+    for (Project.NameKey project : projects) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        ProjectMigrationResult progress = migrator.migrateProject(project, repo, false);
+        skipped += progress.skipped;
+      } catch (IOException e) {
+        ok = false;
+        logger.atWarning().log("Error migrating project " + project, e);
+      }
+      pm.update(1);
+    }
+
+    pm.endTask();
+    ui.message(
+        "Skipped " + skipped + " project" + (skipped == 1 ? "" : "s") + " with no legacy comments");
+
+    if (!ok) {
+      throw new OrmException("Migration failed");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index f1749f4..08f5abb 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -28,6 +28,7 @@
   @Override
   protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
     if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && toMerge.getParentCount() > 0
         && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
       toMerge.setStatusCode(EMPTY_COMMIT);
       return;
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index 00ce7b2..f616e92 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -22,8 +22,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.gerrit.server.util.git.SubmoduleSectionParser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -51,7 +50,7 @@
 
   private static final String GIT_MODULES = ".gitmodules";
 
-  private final RequestId submissionId;
+  private final Branch.NameKey branch;
   Set<SubmoduleSubscription> subscriptions;
 
   @Inject
@@ -60,9 +59,9 @@
       @Assisted Branch.NameKey branch,
       @Assisted MergeOpRepoManager orm)
       throws IOException {
-    this.submissionId = orm.getSubmissionId();
+    this.branch = branch;
     Project.NameKey project = branch.getParentKey();
-    logDebug("Loading .gitmodules of %s for project %s", branch, project);
+    logger.atFine().log("Loading .gitmodules of %s for project %s", branch, project);
     try {
       OpenRepo or = orm.getRepo(project);
       ObjectId id = or.repo.resolve(branch.get());
@@ -74,40 +73,33 @@
       try (TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree())) {
         if (tw == null || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
           subscriptions = Collections.emptySet();
-          logDebug("The .gitmodules file doesn't exist in %s", branch);
+          logger.atFine().log("The .gitmodules file doesn't exist in %s", branch);
           return;
         }
       }
-      BlobBasedConfig bbc;
+      BlobBasedConfig config;
       try {
-        bbc = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
+        config = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
       } catch (ConfigInvalidException e) {
         throw new IOException(
             "Could not read .gitmodules of super project: " + branch.getParentKey(), e);
       }
-      subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl, branch).parseAllSections();
+      subscriptions =
+          new SubmoduleSectionParser(config, canonicalWebUrl, branch).parseAllSections();
     } catch (NoSuchProjectException e) {
       throw new IOException(e);
     }
   }
 
   public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
-    logDebug("Checking for a subscription of %s", src);
+    logger.atFine().log("Checking for a subscription of %s for %s", src, branch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
-        logDebug("Found %s", s);
+        logger.atFine().log("Found %s", s);
         ret.add(s);
       }
     }
     return ret;
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(submissionId + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 06f57b5..9efb976 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -182,14 +183,19 @@
         changeSet.ids().contains(cd.getId())
             && (projectState != null)
             && projectState.statePermitsRead();
-    if (visible
-        && !permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ)) {
+    if (!visible) {
+      return false;
+    }
+
+    try {
+      permissionBackend.user(user).change(cd).database(db).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
       // We thought the change was visible, but it isn't.
       // This can happen if the ACL changes during the
       // completeChangeSet computation, for example.
-      visible = false;
+      return false;
     }
-    return visible;
   }
 
   private SubmitType submitType(ChangeData cd) throws OrmException {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index dedf764..43d5f75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -62,6 +63,8 @@
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -76,7 +79,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -315,7 +317,7 @@
           throw new ResourceConflictException("submit rule error: " + record.errorMessage);
 
         case NOT_READY:
-          throw new ResourceConflictException(describeLabels(cd, record.labels));
+          throw new ResourceConflictException(describeNotReady(cd, record));
 
         case FORCED:
         default:
@@ -336,6 +338,21 @@
     return cd.submitRecords(submitRuleOptions(allowClosed));
   }
 
+  private static String describeNotReady(ChangeData cd, SubmitRecord record) throws OrmException {
+    List<String> blockingConditions = new ArrayList<>();
+    if (record.labels != null) {
+      blockingConditions.add(describeLabels(cd, record.labels));
+    }
+    if (record.requirements != null) {
+      record
+          .requirements
+          .stream()
+          .map(SubmitRequirement::fallbackText)
+          .forEach(blockingConditions::add);
+    }
+    return Joiner.on("; ").join(blockingConditions);
+  }
+
   private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
       throws OrmException {
     List<String> labelResults = new ArrayList<>();
@@ -439,78 +456,83 @@
     this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
-    submissionId = RequestId.forChange(change);
     this.db = db;
-    openRepoManager();
+    this.submissionId = new RequestId(change.getId().toString());
 
-    logDebug("Beginning integration of %s", change);
-    try {
-      ChangeSet indexBackedChangeSet =
-          mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
-      checkState(
-          indexBackedChangeSet.ids().contains(change.getId()),
-          "change %s missing from %s",
-          change.getId(),
-          indexBackedChangeSet);
-      if (indexBackedChangeSet.furtherHiddenChanges()) {
-        throw new AuthException(
-            "A change to be submitted with " + change.getId() + " is not visible");
+    try (TraceContext traceContext =
+        TraceContext.open().addTag(RequestId.Type.SUBMISSION_ID, submissionId)) {
+      openRepoManager();
+
+      logger.atFine().log("Beginning integration of %s", change);
+      try {
+        ChangeSet indexBackedChangeSet =
+            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+        checkState(
+            indexBackedChangeSet.ids().contains(change.getId()),
+            "change %s missing from %s",
+            change.getId(),
+            indexBackedChangeSet);
+        if (indexBackedChangeSet.furtherHiddenChanges()) {
+          throw new AuthException(
+              "A change to be submitted with " + change.getId() + " is not visible");
+        }
+        logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
+
+        // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
+        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+
+        // Count cross-project submissions outside of the retry loop. The chance of a single project
+        // failing increases with the number of projects, so the failure count would be inflated if
+        // this metric were incremented inside of integrateIntoHistory.
+        int projects = cs.projects().size();
+        if (projects > 1) {
+          topicMetrics.topicSubmissions.increment();
+        }
+
+        RetryTracker retryTracker = new RetryTracker();
+        retryHelper.execute(
+            updateFactory -> {
+              long attempt = retryTracker.lastAttemptNumber + 1;
+              boolean isRetry = attempt > 1;
+              if (isRetry) {
+                logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
+                this.ts = TimeUtil.nowTs();
+                openRepoManager();
+              }
+              this.commitStatus = new CommitStatus(cs, isRetry);
+              if (checkSubmitRules) {
+                logger.atFine().log("Checking submit rules and state");
+                checkSubmitRulesAndState(cs, isRetry);
+              } else {
+                logger.atFine().log("Bypassing submit rules");
+                bypassSubmitRules(cs, isRetry);
+              }
+              try {
+                integrateIntoHistory(cs);
+              } catch (IntegrationException e) {
+                logger.atSevere().withCause(e).log("Error from integrateIntoHistory");
+                throw new ResourceConflictException(e.getMessage(), e);
+              }
+              return null;
+            },
+            RetryHelper.options()
+                .listener(retryTracker)
+                // Up to the entire submit operation is retried, including possibly many projects.
+                // Multiply the timeout by the number of projects we're actually attempting to
+                // submit.
+                .timeout(
+                    retryHelper
+                        .getDefaultTimeout(ActionType.CHANGE_UPDATE)
+                        .multipliedBy(cs.projects().size()))
+                .build());
+
+        if (projects > 1) {
+          topicMetrics.topicSubmissionsCompleted.increment();
+        }
+      } catch (IOException e) {
+        // Anything before the merge attempt is an error
+        throw new OrmException(e);
       }
-      logDebug("Calculated to merge %s", indexBackedChangeSet);
-
-      // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-      ChangeSet cs = reloadChanges(indexBackedChangeSet);
-
-      // Count cross-project submissions outside of the retry loop. The chance of a single project
-      // failing increases with the number of projects, so the failure count would be inflated if
-      // this metric were incremented inside of integrateIntoHistory.
-      int projects = cs.projects().size();
-      if (projects > 1) {
-        topicMetrics.topicSubmissions.increment();
-      }
-
-      RetryTracker retryTracker = new RetryTracker();
-      retryHelper.execute(
-          updateFactory -> {
-            long attempt = retryTracker.lastAttemptNumber + 1;
-            boolean isRetry = attempt > 1;
-            if (isRetry) {
-              logDebug("Retrying, attempt #%d; skipping merged changes", attempt);
-              this.ts = TimeUtil.nowTs();
-              openRepoManager();
-            }
-            this.commitStatus = new CommitStatus(cs, isRetry);
-            if (checkSubmitRules) {
-              logDebug("Checking submit rules and state");
-              checkSubmitRulesAndState(cs, isRetry);
-            } else {
-              logDebug("Bypassing submit rules");
-              bypassSubmitRules(cs, isRetry);
-            }
-            try {
-              integrateIntoHistory(cs);
-            } catch (IntegrationException e) {
-              logError("Error from integrateIntoHistory", e);
-              throw new ResourceConflictException(e.getMessage(), e);
-            }
-            return null;
-          },
-          RetryHelper.options()
-              .listener(retryTracker)
-              // Up to the entire submit operation is retried, including possibly many projects.
-              // Multiply the timeout by the number of projects we're actually attempting to submit.
-              .timeout(
-                  retryHelper
-                      .getDefaultTimeout(ActionType.CHANGE_UPDATE)
-                      .multipliedBy(cs.projects().size()))
-              .build());
-
-      if (projects > 1) {
-        topicMetrics.topicSubmissionsCompleted.increment();
-      }
-    } catch (IOException e) {
-      // Anything before the merge attempt is an error
-      throw new OrmException(e);
     }
   }
 
@@ -519,7 +541,7 @@
       orm.close();
     }
     orm = ormProvider.get();
-    orm.setContext(db, ts, caller, submissionId);
+    orm.setContext(db, ts, caller);
   }
 
   private ChangeSet reloadChanges(ChangeSet changeSet) {
@@ -565,7 +587,7 @@
   private void integrateIntoHistory(ChangeSet cs)
       throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
-    logDebug("Beginning merge attempt on %s", cs);
+    logger.atFine().log("Beginning merge attempt on %s", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
 
     ListMultimap<Branch.NameKey, ChangeData> cbb;
@@ -593,7 +615,6 @@
       batchUpdateFactory.execute(
           orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          submissionId,
           dryrun);
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
@@ -693,7 +714,7 @@
     }
 
     try {
-      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+      for (Ref r : or.repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS)) {
         try {
           CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
           if (!commitStatus.commits.values().contains(aac)) {
@@ -707,7 +728,7 @@
       throw new IntegrationException("Failed to determine already accepted commits.", e);
     }
 
-    logDebug("Found %d existing heads", alreadyAccepted.size());
+    logger.atFine().log("Found %d existing heads", alreadyAccepted.size());
     return alreadyAccepted;
   }
 
@@ -721,7 +742,7 @@
 
   private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
       throws IntegrationException {
-    logDebug("Validating %d changes", submitted.size());
+    logger.atFine().log("Validating %d changes", submitted.size());
     Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
     SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
 
@@ -759,7 +780,7 @@
       }
       if (chg.currentPatchSetId() == null) {
         String msg = "Missing current patch set on change";
-        logError(msg + " " + changeId);
+        logger.atSevere().log("%s %s", msg, changeId);
         commitStatus.problem(changeId, msg);
         continue;
       }
@@ -787,19 +808,33 @@
       }
 
       if (!revisions.containsEntry(id, ps.getId())) {
-        // TODO this is actually an error, the branch is gone but we
-        // want to merge the issue. We can't safely do that if the
-        // tip is not reachable.
-        //
+        if (revisions.containsValue(ps.getId())) {
+          // TODO This is actually an error, the patch set ref exists but points to a revision that
+          // is different from the revision that we have stored for the patch set in the change
+          // meta data.
+          commitStatus.logProblem(
+              changeId,
+              "Revision "
+                  + idstr
+                  + " of patch set "
+                  + ps.getPatchSetId()
+                  + " does not match the revision of the patch set ref "
+                  + ps.getId().toRefName());
+          continue;
+        }
+
+        // The patch set ref is not found but we want to merge the change. We can't safely do that
+        // if the patch set ref is missing. In a multi-master setup this can indicate a replication
+        // lag (e.g. the change meta data was already replicated, but the replication of the patch
+        // set ref is still pending).
         commitStatus.logProblem(
             changeId,
-            "Revision "
-                + idstr
-                + " of patch set "
-                + ps.getPatchSetId()
-                + " does not match "
+            "Patch set ref "
                 + ps.getId().toRefName()
-                + " for change");
+                + " not found. Expected patch set ref of "
+                + ps.getPatchSetId()
+                + " to point to revision "
+                + idstr);
         continue;
       }
 
@@ -826,7 +861,7 @@
       commit.add(or.canMergeFlag);
       toSubmit.add(commit);
     }
-    logDebug("Submitting on this run: %s", toSubmit);
+    logger.atFine().log("Submitting on this run: %s", toSubmit);
     return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
@@ -864,7 +899,7 @@
     try {
       return orm.getRepo(project);
     } catch (NoSuchProjectException e) {
-      logWarn("Project " + project + " no longer exists, abandoning open changes.");
+      logger.atWarning().log("Project %s no longer exists, abandoning open changes.", project);
       abandonAllOpenChangeForDeletedProject(project);
     } catch (IOException e) {
       throw new IntegrationException("Error opening project " + project, e);
@@ -877,7 +912,6 @@
       for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
         try (BatchUpdate bu =
             batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
-          bu.setRequestId(submissionId);
           bu.addOp(
               cd.getId(),
               new BatchUpdateOp() {
@@ -906,12 +940,14 @@
           try {
             bu.execute();
           } catch (UpdateException | RestApiException e) {
-            logWarn("Cannot abandon changes for deleted project " + destProject, e);
+            logger.atWarning().withCause(e).log(
+                "Cannot abandon changes for deleted project %s", destProject);
           }
         }
       }
     } catch (OrmException e) {
-      logWarn("Cannot abandon changes for deleted project " + destProject, e);
+      logger.atWarning().withCause(e).log(
+          "Cannot abandon changes for deleted project %s", destProject);
     }
   }
 
@@ -935,28 +971,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(submissionId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logger.atWarning().log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 67059e6..3f07ed7 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -112,7 +111,6 @@
             batchUpdateFactory
                 .create(db, getProjectName(), caller, ts)
                 .setRepository(repo, rw, ins)
-                .setRequestId(submissionId)
                 .setOnSubmitValidators(onSubmitValidatorsFactory.create());
       }
       return update;
@@ -162,7 +160,6 @@
   private ReviewDb db;
   private Timestamp ts;
   private IdentifiedUser caller;
-  private RequestId submissionId;
 
   @Inject
   MergeOpRepoManager(
@@ -178,15 +175,10 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
+  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller) {
     this.db = db;
     this.ts = ts;
     this.caller = caller;
-    this.submissionId = submissionId;
-  }
-
-  public RequestId getSubmissionId() {
-    return submissionId;
   }
 
   public OpenRepo getRepo(Project.NameKey project) throws NoSuchProjectException, IOException {
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 3e9f068..31bcc2a 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -100,17 +101,22 @@
       List<ChangeData> cds = queryProvider.get().byLegacyChangeId(change.getId());
       checkState(cds.size() == 1, "Expected exactly one ChangeData, got " + cds.size());
       ChangeData cd = Iterables.getFirst(cds, null);
-      ProjectState projectState = projectCache.checkedGet(cd.project());
-      ChangeSet changeSet =
-          new ChangeSet(
-              cd,
-              projectState != null
-                  && projectState.statePermitsRead()
-                  && permissionBackend
-                      .user(user)
-                      .change(cd)
-                      .database(db)
-                      .test(ChangePermission.READ));
+
+      boolean visible = false;
+      if (cd != null) {
+        ProjectState projectState = projectCache.checkedGet(cd.project());
+
+        if (projectState.statePermitsRead()) {
+          try {
+            permissionBackend.user(user).change(cd).database(db).check(ChangePermission.READ);
+            visible = true;
+          } catch (AuthException e) {
+            // Do nothing.
+          }
+        }
+      }
+
+      ChangeSet changeSet = new ChangeSet(cd, visible);
       if (wholeTopicEnabled(cfg)) {
         return completeChangeSetIncludingTopics(db, changeSet, user);
       }
@@ -203,8 +209,15 @@
   private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
       throws PermissionBackendException, IOException {
     ProjectState projectState = projectCache.checkedGet(cd.project());
-    return projectState != null
-        && projectState.statePermitsRead()
-        && permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ);
+    if (projectState == null || !projectState.statePermitsRead()) {
+      return false;
+    }
+
+    try {
+      permissionBackend.user(user).change(cd).database(db).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index 055e3cc..ed9bfeb 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -63,8 +63,8 @@
 
   public static Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
     return Streams.concat(
-            repo.getRefDatabase().getRefs(Constants.R_HEADS).values().stream(),
-            repo.getRefDatabase().getRefs(Constants.R_TAGS).values().stream())
+            repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS).stream(),
+            repo.getRefDatabase().getRefsByPrefix(Constants.R_TAGS).stream())
         .map(Ref::getObjectId)
         .filter(Objects::nonNull)
         .collect(toSet());
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 7b7ae48..6e3e0b8 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -43,13 +43,13 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2cb0744..7f70f37 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 62dabae..51dad5b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,7 +22,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -54,7 +53,6 @@
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -103,7 +101,8 @@
 
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
-    logDebug("%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
     checkState(
         ctx.getRevWalk() == args.rw,
         "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
@@ -117,18 +116,18 @@
     if (alreadyMergedCommit == null) {
       updateRepoImpl(ctx);
     } else {
-      logDebug("Already merged as %s", alreadyMergedCommit.name());
+      logger.atFine().log("Already merged as %s", alreadyMergedCommit.name());
     }
     CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
 
     if (Objects.equals(tipBefore, tipAfter)) {
-      logDebug("Did not move tip", getClass().getSimpleName());
+      logger.atFine().log("Did not move tip");
       return;
     } else if (tipAfter == null) {
-      logDebug("No merge tip, no update to perform");
+      logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logDebug("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -144,7 +143,7 @@
       throws IntegrationException {
     String refName = getDest().get();
     if (RefNames.REFS_CONFIG.equals(refName)) {
-      logDebug("Loading new configuration from %s", RefNames.REFS_CONFIG);
+      logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
         ProjectConfig cfg = new ProjectConfig(getProject());
         cfg.load(ctx.getRevWalk(), commit);
@@ -184,8 +183,7 @@
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
-    Collections.sort(
-        commits,
+    commits.sort(
         ReviewDbUtil.intKeyOrdering().reverse().onResultOf(CodeReviewCommit::getPatchsetId));
     CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
     if (result == null) {
@@ -216,7 +214,8 @@
 
   @Override
   public final boolean updateChange(ChangeContext ctx) throws Exception {
-    logDebug("%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
     toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
     PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
     PatchSet.Id newPsId;
@@ -225,12 +224,12 @@
       // Either another thread won a race, or we are retrying a whole topic submission after one
       // repo failed with lock failure.
       if (alreadyMergedCommit == null) {
-        logDebug(
+        logger.atFine().log(
             "Change is already merged according to its status, but we were unable to find it"
                 + " merged into the current tip (%s)",
             args.mergeTip.getCurrentTip().name());
       } else {
-        logDebug("Change is already merged");
+        logger.atFine().log("Change is already merged");
       }
       changeAlreadyMerged = true;
       return false;
@@ -276,7 +275,7 @@
     checkNotNull(commit, "missing commit for change " + id);
     CommitMergeStatus s = commit.getStatusCode();
     checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
-    logDebug("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
+    logger.atFine().log("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
     setApproval(ctx, args.caller);
 
     mergeResultRev =
@@ -302,7 +301,7 @@
   private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
       throws IOException, OrmException {
     PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
-    logDebug("Fixing up already-merged patch set %s", psId);
+    logger.atFine().log("Fixing up already-merged patch set %s", psId);
     PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
     ctx.getRevWalk().parseBody(alreadyMergedCommit);
     ctx.getChange()
@@ -310,7 +309,7 @@
             psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
     PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     if (existing != null) {
-      logDebug("Patch set row exists, only updating change");
+      logger.atFine().log("Patch set row exists, only updating change");
       return existing;
     }
     // No patch set for the already merged commit, although we know it came form
@@ -336,7 +335,7 @@
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
     PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
 
-    logDebug("Add approval for %s", id);
+    logger.atFine().log("Add approval for %s", id);
     ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
     origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
     LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
@@ -357,12 +356,7 @@
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
     for (PatchSetApproval psa :
         args.approvalsUtil.byPatchSet(
-            ctx.getDb(),
-            ctx.getNotes(),
-            ctx.getUser(),
-            psId,
-            ctx.getRevWalk(),
-            ctx.getRepoView().getConfig())) {
+            ctx.getDb(), ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
       byKey.put(psa.getKey(), psa);
     }
 
@@ -376,7 +370,7 @@
     // was added. So we need to make sure votes are accurate now. This way if
     // permissions get modified in the future, historical records stay accurate.
     LabelNormalizer.Result normalized =
-        args.labelNormalizer.normalize(ctx.getNotes(), ctx.getUser(), byKey.values());
+        args.labelNormalizer.normalize(ctx.getNotes(), byKey.values());
     update.putApproval(submitter.getLabel(), submitter.getValue());
     saveApprovals(normalized, ctx, update, false);
     return normalized;
@@ -402,7 +396,7 @@
     // change happened.
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
-        logDebug("Adding submit label %s", psa);
+        logger.atFine().log("Adding submit label %s", psa);
         update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
       }
     }
@@ -493,7 +487,7 @@
   private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
     Change c = ctx.getChange();
     ReviewDb db = ctx.getDb();
-    logDebug("Setting change %s merged", c.getId());
+    logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toStringForStorage());
 
@@ -516,7 +510,7 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logDebug("Skipping post-update steps for change %s", getId());
+      logger.atFine().log("Skipping post-update steps for change %s", getId());
       return;
     }
     postUpdateImpl(ctx);
@@ -600,37 +594,4 @@
           "cannot update gitlink for the commit at branch: " + args.destBranch);
     }
   }
-
-  protected final void logDebug(String msg) {
-    logger.atFine().log(this.args.submissionId + msg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(this.args.submissionId + msg, arg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2);
-  }
-
-  protected final void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  protected final void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 7e9fa6a..50be62a 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.submit;
 
 import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -140,17 +141,31 @@
   private final MergeOpRepoManager orm;
   private final Map<Branch.NameKey, GitModules> branchGitModules;
 
-  // always update-to-current branch tips during submit process
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<Branch.NameKey> updatedBranches;
+
+  /**
+   * Current branch tips, taking into account commits created during the submit process as well as
+   * submodule updates produced by this class.
+   */
   private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
-  // branches for all the submitting changes
-  private final Set<Branch.NameKey> updatedBranches;
-  // branches which in either a submodule or a superproject
+
+  /**
+   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
+   * which are subscribed to by some superproject.
+   */
   private final Set<Branch.NameKey> affectedBranches;
-  // sorted version of affectedBranches
+
+  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
   private final ImmutableSet<Branch.NameKey> sortedBranches;
-  // map of superproject branch and its submodule subscriptions
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
   private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
-  // map of superproject and its branches which has submodule subscriptions
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
   private SubmoduleOp(
@@ -174,29 +189,57 @@
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = updatedBranches;
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
     this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
     this.affectedBranches = new HashSet<>();
     this.branchTips = new HashMap<>();
     this.branchGitModules = new HashMap<>();
     this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMap();
+    this.sortedBranches = calculateSubscriptionMaps();
   }
 
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap() throws SubmoduleException {
+  /**
+   * Calculate the internal maps used by the operation.
+   *
+   * <p>In addition to the return value, the following fields are populated as a side effect:
+   *
+   * <ul>
+   *   <li>{@link #affectedBranches}
+   *   <li>{@link #targets}
+   *   <li>{@link #branchesByProject}
+   * </ul>
+   *
+   * @return the ordered set to be stored in {@link #sortedBranches}.
+   * @throws SubmoduleException if an error occurred walking projects.
+   */
+  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
+  // mutable maps, which makes this whole class difficult to understand.
+  //
+  // A cleaner architecture for this process might be:
+  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
+  //      structure representing the subscription graph, using a separate class with a properly-
+  //      documented interface.
+  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
+  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
+  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
+  //      relevant updates.
+  //
+  // In addition to improving readability, this approach has the advantage of making (1) and (2)
+  // testable using small tests.
+  private ImmutableSet<Branch.NameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
-      logDebug("Updating superprojects disabled");
+      logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
-    logDebug("Calculating superprojects - submodules map");
+    logger.atFine().log("Calculating superprojects - submodules map");
     LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
     for (Branch.NameKey updatedBranch : updatedBranches) {
       if (allVisited.contains(updatedBranch)) {
         continue;
       }
 
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(), allVisited);
+      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
     }
 
     // Since the searchForSuperprojects will add all branches (related or
@@ -213,7 +256,7 @@
       LinkedHashSet<Branch.NameKey> currentVisited,
       LinkedHashSet<Branch.NameKey> allVisited)
       throws SubmoduleException {
-    logDebug("Now processing %s", current);
+    logger.atFine().log("Now processing %s", current);
 
     if (currentVisited.contains(current)) {
       throw new SubmoduleException(
@@ -275,9 +318,9 @@
   private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
       throws IOException {
     Collection<Branch.NameKey> ret = new HashSet<>();
-    logDebug("Inspecting SubscribeSection %s", s);
+    logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
-      logDebug("Inspecting [matching] ref %s", r);
+      logger.atFine().log("Inspecting [matching] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -295,7 +338,7 @@
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logDebug("Inspecting [all] ref %s", r);
+      logger.atFine().log("Inspecting [all] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -309,7 +352,7 @@
         continue;
       }
 
-      for (Ref ref : or.repo.getRefDatabase().getRefs(RefNames.REFS_HEADS).values()) {
+      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
         if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
           continue;
         }
@@ -319,17 +362,18 @@
         }
       }
     }
-    logDebug("Returning possible branches: %s for project %s", ret, s.getProject());
+    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
     return ret;
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
       Branch.NameKey srcBranch) throws IOException {
-    logDebug("Calculating possible superprojects for %s", srcBranch);
+    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey srcProject = srcBranch.getParentKey();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
-      logDebug("Checking subscribe section %s", s);
+      logger.atFine().log("Checking subscribe section %s", s);
       Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
       for (Branch.NameKey targetBranch : branches) {
         Project.NameKey targetProject = targetBranch.getParentKey();
@@ -337,11 +381,11 @@
           OpenRepo or = orm.getRepo(targetProject);
           ObjectId id = or.repo.resolve(targetBranch.get());
           if (id == null) {
-            logDebug("The branch %s doesn't exist.", targetBranch);
+            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
             continue;
           }
         } catch (NoSuchProjectException e) {
-          logDebug("The project %s doesn't exist", targetProject);
+          logger.atFine().log("The project %s doesn't exist", targetProject);
           continue;
         }
 
@@ -353,7 +397,7 @@
         ret.addAll(m.subscribedTo(srcBranch));
       }
     }
-    logDebug("Calculated superprojects for %s are %s", srcBranch, ret);
+    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
     return ret;
   }
 
@@ -376,15 +420,14 @@
           }
         }
       }
-      batchUpdateFactory.execute(
-          orm.batchUpdates(superProjects), BatchUpdateListener.NONE, orm.getSubmissionId(), false);
+      batchUpdateFactory.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
     } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
   /** Create a separate gitlink commit */
-  public CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
+  private CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -406,14 +449,18 @@
       addBranchTip(subscriber, currentCommit);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     int count = 0;
 
-    List<SubmoduleSubscription> subscriptions = new ArrayList<>(targets.get(subscriber));
-    Collections.sort(subscriptions, comparing(SubmoduleSubscription::getPath));
+    List<SubmoduleSubscription> subscriptions =
+        targets
+            .get(subscriber)
+            .stream()
+            .sorted(comparing(SubmoduleSubscription::getPath))
+            .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
       if (count > 0) {
         msgbuf.append("\n\n");
@@ -421,9 +468,11 @@
       RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
       count++;
       if (newCommit != null) {
+        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
         if (author == null) {
-          author = newCommit.getAuthorIdent();
-        } else if (!author.equals(newCommit.getAuthorIdent())) {
+          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
+        } else if (!author.getName().equals(newCommitAuthor.getName())
+            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
           author = myIdent;
         }
       }
@@ -450,8 +499,7 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  public CodeReviewCommit composeGitlinksCommit(
-      Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -460,7 +508,7 @@
       throw new SubmoduleException("Cannot access superproject", e);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     for (SubmoduleSubscription s : targets.get(subscriber)) {
@@ -494,6 +542,7 @@
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleException, IOException {
+    logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
       subOr = orm.getRepo(s.getSubmodule().getParentKey());
@@ -514,13 +563,33 @@
                 + "doesn't have gitlink file mode.";
         throw new SubmoduleException(errMsg);
       }
-      oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      // Parse the current gitlink entry commit in the subproject repo. This is used to add a
+      // shortlog for this submodule to the commit message in the superproject.
+      //
+      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
+      // check that the old gitlink is a commit that actually exists. If not, then there is an
+      // inconsistency between the superproject and subproject state, and we don't want to risk
+      // making things worse by updating the gitlink to something else.
+      try {
+        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
     }
 
     final CodeReviewCommit newCommit;
     if (branchTips.containsKey(s.getSubmodule())) {
+      // This submodule's branch was updated as part of this specific submit batch: update the
+      // gitlink to point to the new commit from the batch.
       newCommit = branchTips.get(s.getSubmodule());
     } else {
+      // For whatever reason, this submodule was not updated as part of this submit batch, but the
+      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
+      // changed since the last time the gitlink was updated, and roll that update into the same
+      // commit as all other submodule updates.
       Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().get());
       if (ref == null) {
         ed.add(new DeletePath(s.getPath()));
@@ -562,7 +631,8 @@
     msgbuf.append(" from branch '");
     msgbuf.append(s.getSubmodule().getShortName());
     msgbuf.append("'");
-    msgbuf.append("\n  to " + newCommit.getName());
+    msgbuf.append("\n  to ");
+    msgbuf.append(newCommit.getName());
 
     // newly created submodule gitlink, do not append whole history
     if (oldCommit == null) {
@@ -613,7 +683,7 @@
     return dc;
   }
 
-  public ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
     for (Project.NameKey project : branchesByProject.keySet()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
@@ -656,7 +726,7 @@
     projects.add(project);
   }
 
-  public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
+  ImmutableSet<Branch.NameKey> getBranchesInOrder() {
     LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
@@ -665,27 +735,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  public boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(Branch.NameKey branch) {
     return targets.containsKey(branch);
   }
 
-  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  public void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, Branch.NameKey branch) {
     bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/TestHelperOp.java b/java/com/google/gerrit/server/submit/TestHelperOp.java
index 2f0a3f6..bbb198a 100644
--- a/java/com/google/gerrit/server/submit/TestHelperOp.java
+++ b/java/com/google/gerrit/server/submit/TestHelperOp.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestId;
 import java.io.IOException;
 import java.util.Queue;
 import org.eclipse.jgit.lib.ObjectId;
@@ -30,27 +28,22 @@
 
   private final Change.Id changeId;
   private final TestSubmitInput input;
-  private final RequestId submissionId;
 
   TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
     this.changeId = changeId;
     this.input = (TestSubmitInput) args.submitInput;
-    this.submissionId = args.submissionId;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
     Queue<Boolean> q = input.generateLockFailures;
     if (q != null && !q.isEmpty() && q.remove()) {
-      logDebug("Adding bogus ref update to trigger lock failure, via change %s", changeId);
+      logger.atFine().log(
+          "Adding bogus ref update to trigger lock failure, via change %s", changeId);
       ctx.addRefUpdate(
           ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
           ObjectId.zeroId(),
           "refs/test/" + getClass().getSimpleName());
     }
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index dd3cc73..60cacee 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -38,12 +38,12 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -126,10 +126,7 @@
 
     @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
-        Collection<BatchUpdate> updates,
-        BatchUpdateListener listener,
-        @Nullable RequestId requestId,
-        boolean dryRun)
+        Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryRun)
         throws UpdateException, RestApiException {
       checkNotNull(listener);
       checkDifferentProject(updates);
@@ -141,11 +138,11 @@
       if (migration.disableChangeReviewDb()) {
         ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        NoteDbBatchUpdate.execute(noteDbUpdates, listener, dryRun);
       } else {
         ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, dryRun);
       }
     }
 
@@ -159,20 +156,6 @@
     }
   }
 
-  static void setRequestIds(
-      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
-  }
-
   static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
@@ -248,7 +231,6 @@
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
-  protected RequestId requestId;
   protected PushCertificate pushCert;
   protected String refLogMessage;
 
@@ -284,11 +266,6 @@
 
   protected abstract Context newContext();
 
-  public BatchUpdate setRequestId(RequestId requestId) {
-    this.requestId = requestId;
-    return this;
-  }
-
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
     checkState(this.repoView == null, "repo already set");
     repoView = new RepoView(repo, revWalk, inserter);
@@ -384,39 +361,39 @@
     return this;
   }
 
-  protected void logDebug(String msg, Throwable t) {
+  protected static void logDebug(String msg, Throwable t) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().withCause(t).log(requestId + "%s", msg);
+    if (RequestId.isSet()) {
+      logger.atFine().withCause(t).log("%s", msg);
     }
   }
 
-  protected void logDebug(String msg) {
+  protected static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg) {
+  protected static void logDebug(String msg, @Nullable Object arg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
+  protected static void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg1, arg2);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg1, arg2);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
index 8612fac..abe865c 100644
--- a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -35,7 +34,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -68,15 +66,11 @@
   }
 
   static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<NoteDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
 
     try {
       @SuppressWarnings("deprecation")
@@ -293,7 +287,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
index 8839dbe..62cf6b1 100644
--- a/java/com/google/gerrit/server/update/RepoView.java
+++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toMap;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
@@ -133,14 +132,15 @@
    *
    * @param prefix ref prefix; must end in '/' or else be empty.
    * @return a map of ref suffixes to SHA-1s. The refs are all under {@code prefix} and have the
-   *     prefix stripped; this matches the behavior of {@link
-   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)}.
+   *     prefix stripped.
    * @throws IOException if an error occurred.
    */
   public Map<String, ObjectId> getRefs(String prefix) throws IOException {
     Map<String, ObjectId> result =
-        new HashMap<>(
-            Maps.transformValues(repo.getRefDatabase().getRefs(prefix), Ref::getObjectId));
+        repo.getRefDatabase()
+            .getRefsByPrefix(prefix)
+            .stream()
+            .collect(toMap(r -> r.getName().substring(prefix.length()), Ref::getObjectId));
 
     // First, overwrite any cached reads from the underlying RepoRefCache. If any of these differ,
     // it's because a ref was updated after the RepoRefCache read it. It feels a little odd to
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 132c04b..10e3455 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -31,6 +31,7 @@
 import com.google.common.base.Predicate;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Maps;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Counter1;
@@ -52,6 +53,8 @@
 
 @Singleton
 public class RetryHelper {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @FunctionalInterface
   public interface ChangeAction<T> {
     T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
@@ -277,6 +280,9 @@
       retryerBuilder.withRetryListener(listener);
       return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
     } finally {
+      if (listener.getAttemptCount() > 1) {
+        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+      }
       metrics.attemptCounts.record(actionType, listener.getAttemptCount());
     }
   }
diff --git a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
new file mode 100644
index 0000000..7620386
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 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.update;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+
+public abstract class RetryingRestCollectionModifyView<
+        P extends RestResource, C extends RestResource, I, O>
+    implements RestCollectionModifyView<P, C, I> {
+  private final RetryHelper retryHelper;
+
+  protected RetryingRestCollectionModifyView(RetryHelper retryHelper) {
+    this.retryHelper = retryHelper;
+  }
+
+  @Override
+  public final O apply(P parentResource, I input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, parentResource, input));
+  }
+
+  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, P parentResource, I input)
+      throws Exception;
+}
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 3c6f6fd..df50bd5 100644
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -28,7 +28,6 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
@@ -51,6 +50,7 @@
 import com.google.gerrit.server.git.InsertedObject;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
@@ -58,7 +58,6 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -240,15 +239,11 @@
   }
 
   static void execute(
-      ImmutableList<ReviewDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<ReviewDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
     try {
       Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
@@ -358,7 +353,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
@@ -616,7 +611,6 @@
     NoteDbUpdateManager.StagedResult noteDbResult;
     boolean dirty;
     boolean deleted;
-    private String taskId;
 
     private ChangeTask(
         Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
@@ -628,27 +622,30 @@
 
     @Override
     public Void call() throws Exception {
-      taskId = id.toString() + "-" + Thread.currentThread().getId();
-      if (Thread.currentThread() == mainThread) {
-        initRepository();
-        Repository repo = repoView.getRepository();
-        try (RevWalk rw = new RevWalk(repo)) {
-          call(ReviewDbBatchUpdate.this.db, repo, rw);
+      try (TraceContext traceContext =
+          TraceContext.open()
+              .addTag("TASK_ID", id.toString() + "-" + Thread.currentThread().getId())) {
+        if (Thread.currentThread() == mainThread) {
+          initRepository();
+          Repository repo = repoView.getRepository();
+          try (RevWalk rw = new RevWalk(repo)) {
+            call(ReviewDbBatchUpdate.this.db, repo, rw);
+          }
+        } else {
+          // Possible optimization: allow Ops to declare whether they need to
+          // access the repo from updateChange, and don't open in this thread
+          // unless we need it. However, as of this writing the only operations
+          // that are executed in parallel are during ReceiveCommits, and they
+          // all need the repo open anyway. (The non-parallel case above does not
+          // reopen the repo.)
+          try (ReviewDb threadLocalDb = schemaFactory.open();
+              Repository repo = repoManager.openRepository(project);
+              RevWalk rw = new RevWalk(repo)) {
+            call(threadLocalDb, repo, rw);
+          }
         }
-      } else {
-        // Possible optimization: allow Ops to declare whether they need to
-        // access the repo from updateChange, and don't open in this thread
-        // unless we need it. However, as of this writing the only operations
-        // that are executed in parallel are during ReceiveCommits, and they
-        // all need the repo open anyway. (The non-parallel case above does not
-        // reopen the repo.)
-        try (ReviewDb threadLocalDb = schemaFactory.open();
-            Repository repo = repoManager.openRepository(project);
-            RevWalk rw = new RevWalk(repo)) {
-          call(threadLocalDb, repo, rw);
-        }
+        return null;
       }
-      return null;
     }
 
     private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
@@ -822,14 +819,6 @@
     private boolean isNewChange(Change.Id id) {
       return newChanges.containsKey(id);
     }
-
-    private void logDebug(String msg, Throwable t) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, t);
-    }
-
-    private void logDebug(String msg, Object... args) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, args);
-    }
   }
 
   private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index a9a22d9..276df06 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -22,16 +22,6 @@
 /** Simple class to produce 4 billion keys randomly distributed. */
 @Singleton
 public class IdGenerator {
-  /** Format an id created by this class as a hex string. */
-  public static String format(int id) {
-    final char[] r = new char[8];
-    for (int p = 7; 0 <= p; p--) {
-      final int h = id & 0xf;
-      r[p] = h < 10 ? (char) ('0' + h) : (char) ('a' + (h - 10));
-      id >>= 4;
-    }
-    return new String(r);
-  }
 
   private final AtomicInteger gen;
 
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index e7d00f0..a049169 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
-import java.util.Map;
+import java.util.List;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -89,9 +89,9 @@
   }
 
   private static Capable checkMagicBranchRef(String branchName, Repository repo, Project project) {
-    Map<String, Ref> blockingFors;
+    List<Ref> blockingFors;
     try {
-      blockingFors = repo.getRefDatabase().getRefs(branchName);
+      blockingFors = repo.getRefDatabase().getRefsByPrefix(branchName);
     } catch (IOException err) {
       String projName = project.getName();
       logger.atWarning().withCause(err).log("Cannot scan refs in '%s'", projName);
@@ -101,7 +101,7 @@
       String projName = project.getName();
       logger.atSevere().log(
           "Repository '%s' needs the following refs removed to receive changes: %s",
-          projName, blockingFors.keySet());
+          projName, blockingFors);
       return new Capable("One or more " + branchName + " names blocks change upload");
     }
 
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index 351fbd4..de8b3aa 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -38,7 +38,7 @@
 
   @Override
   public void start() {
-    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout);
+    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
     Logger logger = LogManager.getLogger(logName);
     logger.removeAppender(logName);
     logger.addAppender(asyncAppender);
diff --git a/java/com/google/gerrit/server/util/SystemLog.java b/java/com/google/gerrit/server/util/SystemLog.java
index 224a6d9..ec2f386 100644
--- a/java/com/google/gerrit/server/util/SystemLog.java
+++ b/java/com/google/gerrit/server/util/SystemLog.java
@@ -77,13 +77,18 @@
   }
 
   private AsyncAppender createAsyncAppender(String name, Layout layout, boolean rotate) {
+    return createAsyncAppender(name, layout, rotate, false);
+  }
+
+  public AsyncAppender createAsyncAppender(
+      String name, Layout layout, boolean rotate, boolean forPlugin) {
     AsyncAppender async = new AsyncAppender();
     async.setName(name);
     async.setBlocking(true);
     async.setBufferSize(asyncLoggingBufferSize);
     async.setLocationInfo(false);
 
-    if (shouldConfigure()) {
+    if (forPlugin || shouldConfigure()) {
       async.addAppender(createAppender(site.logs_dir, name, layout, rotate));
     } else {
       Appender appender = LogManager.getLogger(name).getAppender(name);
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
new file mode 100644
index 0000000..81ca9cd
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -0,0 +1,9 @@
+java_library(
+    name = "git",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
similarity index 89%
rename from java/com/google/gerrit/server/util/SubmoduleSectionParser.java
rename to java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index 6de2fef..f05d1d7 100644
--- a/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2011 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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.util.git;
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
@@ -43,20 +43,20 @@
  */
 public class SubmoduleSectionParser {
 
-  private final Config bbc;
+  private final Config config;
   private final String canonicalWebUrl;
   private final Branch.NameKey superProjectBranch;
 
   public SubmoduleSectionParser(
-      Config bbc, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
-    this.bbc = bbc;
+      Config config, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
+    this.config = config;
     this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
   }
 
   public Set<SubmoduleSubscription> parseAllSections() {
     Set<SubmoduleSubscription> parsedSubscriptions = new HashSet<>();
-    for (String id : bbc.getSubsections("submodule")) {
+    for (String id : config.getSubsections("submodule")) {
       final SubmoduleSubscription subscription = parse(id);
       if (subscription != null) {
         parsedSubscriptions.add(subscription);
@@ -66,9 +66,9 @@
   }
 
   private SubmoduleSubscription parse(String id) {
-    final String url = bbc.getString("submodule", id, "url");
-    final String path = bbc.getString("submodule", id, "path");
-    String branch = bbc.getString("submodule", id, "branch");
+    final String url = config.getString("submodule", id, "url");
+    final String path = config.getString("submodule", id, "path");
+    String branch = config.getString("submodule", id, "branch");
 
     try {
       if (url != null
diff --git a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index 9f152a5..996ad87 100644
--- a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
 import java.util.Map;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 6c810a3..47b1d89 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -11,9 +11,11 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 9fc4cda..2ab13ee 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -86,15 +86,22 @@
     }
     for (ChangeNotes notes : matched) {
       if (!changes.containsKey(notes.getChangeId())
-          && inProject(projectState, notes.getProjectName())
-          && (canMaintainServer
-              || (permissionBackend
-                      .currentUser()
-                      .change(notes)
-                      .database(db)
-                      .test(ChangePermission.READ)
-                  && projectState.statePermitsRead()))) {
-        toAdd.add(notes);
+          && inProject(projectState, notes.getProjectName())) {
+        if (canMaintainServer) {
+          toAdd.add(notes);
+          continue;
+        }
+
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+
+        try {
+          permissionBackend.currentUser().change(notes).database(db).check(ChangePermission.READ);
+          toAdd.add(notes);
+        } catch (AuthException e) {
+          // Do nothing.
+        }
       }
     }
 
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 3eef4d6..1fdf7d8 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -77,11 +78,12 @@
     int threads = cfg.getInt("sshd", "commandStartThreads", 2);
     startExecutor = workQueue.createQueue(threads, "SshCommandStart", true);
     destroyExecutor =
-        Executors.newSingleThreadExecutor(
-            new ThreadFactoryBuilder()
-                .setNameFormat("SshCommandDestroy-%s")
-                .setDaemon(true)
-                .build());
+        new LoggingContextAwareExecutorService(
+            Executors.newSingleThreadExecutor(
+                new ThreadFactoryBuilder()
+                    .setNameFormat("SshCommandDestroy-%s")
+                    .setDaemon(true)
+                    .build()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 3e42ebe..99c8724 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,11 +14,19 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.server.logging.TraceContext;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Option(name = "--trace", usage = "enable request tracing")
+  private boolean trace;
+
+  @Option(name = "--trace-id", usage = "trace ID (can only be set if --trace was set too)")
+  private String traceId;
+
   protected PrintWriter stdout;
   protected PrintWriter stderr;
 
@@ -31,7 +39,7 @@
             parseCommandLine();
             stdout = toPrintWriter(out);
             stderr = toPrintWriter(err);
-            try {
+            try (TraceContext traceContext = enableTracing()) {
               SshCommand.this.run();
             } finally {
               stdout.flush();
@@ -42,4 +50,14 @@
   }
 
   protected abstract void run() throws UnloggedFailure, Failure, Exception;
+
+  private TraceContext enableTracing() throws UnloggedFailure {
+    if (!trace && traceId != null) {
+      throw die("A trace ID can only be set if --trace was specified.");
+    }
+    return TraceContext.newTrace(
+        trace,
+        traceId,
+        (tagName, traceId) -> stderr.println(String.format("%s: %s", tagName, traceId)));
+  }
 }
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index b573062..81ce91d 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -86,6 +86,7 @@
   @Override
   public void evict(String username) {
     if (username != null) {
+      logger.atFine().log("Evict SSH key for username %s", username);
       cache.invalidate(username);
     }
   }
@@ -102,6 +103,8 @@
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
+      logger.atFine().log("Loading SSH keys for account with username %s", username);
+
       Optional<ExternalId> user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
       if (!user.isPresent()) {
         return NO_SUCH_USER;
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index b6c2d19..98c649d 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.config.ConfigUpdatedEvent;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
@@ -277,7 +277,7 @@
   }
 
   private static String id(int id) {
-    return IdGenerator.format(id);
+    return HexFormat.fromInt(id);
   }
 
   void audit(Context ctx, Object result, String cmd) {
@@ -298,7 +298,7 @@
       created = TimeUtil.nowMs();
     } else {
       SshSession session = ctx.getSession();
-      sessionId = IdGenerator.format(session.getSessionId());
+      sessionId = HexFormat.fromInt(session.getSessionId());
       currentUser = session.getUser();
       created = ctx.created;
     }
diff --git a/java/com/google/gerrit/sshd/commands/ApproveOption.java b/java/com/google/gerrit/sshd/commands/ApproveOption.java
index 633eaa0..cda340d 100644
--- a/java/com/google/gerrit/sshd/commands/ApproveOption.java
+++ b/java/com/google/gerrit/sshd/commands/ApproveOption.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import java.lang.annotation.Annotation;
@@ -114,6 +116,16 @@
     return false;
   }
 
+  @Override
+  public String[] forbids() {
+    return null;
+  }
+
+  @Override
+  public boolean help() {
+    return false;
+  }
+
   String getLabelName() {
     return type.getName();
   }
@@ -150,7 +162,7 @@
                 + " for \""
                 + name
                 + "\"";
-        throw new CmdLineException(owner, e);
+        throw new CmdLineException(owner, localizable(e));
       }
       return value;
     }
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 9dc9a50..8a37cce 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -20,9 +20,11 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.CreateAccount;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -66,10 +68,12 @@
   @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
   private String username;
 
-  @Inject private CreateAccount.Factory createAccountFactory;
+  @Inject private CreateAccount createAccount;
 
   @Override
-  protected void run() throws OrmException, IOException, ConfigInvalidException, UnloggedFailure {
+  protected void run()
+      throws OrmException, IOException, ConfigInvalidException, UnloggedFailure,
+          PermissionBackendException {
     AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
@@ -78,7 +82,7 @@
     input.httpPassword = httpPassword;
     input.groups = Lists.transform(groups, AccountGroup.Id::toString);
     try {
-      createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
+      createAccount.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(username), input);
     } catch (RestApiException e) {
       throw die(e.getMessage());
     }
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 5a83b01..917c138 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers;
 import com.google.gerrit.server.restapi.group.AddSubgroups;
 import com.google.gerrit.server.restapi.group.CreateGroup;
@@ -91,7 +92,7 @@
     initialGroups.add(id);
   }
 
-  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private CreateGroup createGroup;
 
   @Inject private GroupsCollection groups;
 
@@ -100,7 +101,9 @@
   @Inject private AddSubgroups addSubgroups;
 
   @Override
-  protected void run() throws Failure, OrmException, IOException, ConfigInvalidException {
+  protected void run()
+      throws Failure, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     try {
       GroupResource rsrc = createGroup();
 
@@ -117,7 +120,8 @@
   }
 
   private GroupResource createGroup()
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -126,12 +130,14 @@
       input.ownerId = String.valueOf(ownerGroupId.get());
     }
 
-    GroupInfo group = createGroupFactory.create(groupName).apply(TopLevelResource.INSTANCE, input);
+    GroupInfo group =
+        createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName), input);
     return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
   }
 
   private void addMembers(GroupResource rsrc)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     AddMembers.Input input =
         AddMembers.Input.fromMembers(
             initialMembers.stream().map(Object::toString).collect(toList()));
@@ -139,7 +145,8 @@
   }
 
   private void addSubgroups(GroupResource rsrc)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     AddSubgroups.Input input =
         AddSubgroups.Input.fromGroups(
             initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 0e36d53..1c857e4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -114,7 +114,7 @@
     command(gerrit, SetMembersCommand.class);
     command(gerrit, CreateBranchCommand.class);
     command(gerrit, SetAccountCommand.class);
-    command(gerrit, AdminSetParent.class);
+    command(gerrit, SetParentCommand.class);
 
     command(testSubmit, TestSubmitRuleCommand.class);
     command(testSubmit, TestSubmitTypeCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
similarity index 88%
rename from java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
rename to java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 407bbd0..56b00a5 100644
--- a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.Index;
+import com.google.gerrit.server.restapi.project.IndexChanges;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -28,10 +28,10 @@
 import org.kohsuke.args4j.Argument;
 
 @RequiresAnyCapability({MAINTAIN_SERVER})
-@CommandMetaData(name = "project", description = "Index changes of a project")
-final class IndexProjectCommand extends SshCommand {
+@CommandMetaData(name = "changes-in-project", description = "Index changes of a project")
+final class IndexChangesInProjectCommand extends SshCommand {
 
-  @Inject private Index index;
+  @Inject private IndexChanges index;
 
   @Argument(
       index = 0,
diff --git a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 599c9dc..332ed69 100644
--- a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -40,6 +40,6 @@
       command(index, IndexStartCommand.class);
     }
     command(index, IndexChangesCommand.class);
-    command(index, IndexProjectCommand.class);
+    command(index, IndexChangesInProjectCommand.class);
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 2d6b1b3..1565ecb 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.ListMembers;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.PrintWriter;
 import java.util.List;
@@ -68,7 +68,7 @@
       this.groupCache = groupCache;
     }
 
-    void display(PrintWriter writer) throws OrmException {
+    void display(PrintWriter writer) throws PermissionBackendException {
       Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
       String errorText = "Group not found or not visible\n";
 
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 6c8dcd6..7c34716 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -38,7 +38,6 @@
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.errors.UnpackException;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.Option;
@@ -148,9 +147,9 @@
               .append("\n");
         }
 
-        Map<String, Ref> allRefs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+        List<Ref> allRefs = rp.getRepository().getRefDatabase().getRefs();
         List<Ref> hidden = new ArrayList<>();
-        for (Ref ref : allRefs.values()) {
+        for (Ref ref : allRefs) {
           if (!adv.containsKey(ref.getName())) {
             hidden.add(ref);
           }
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 379fc68..9bcb103 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -18,9 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.common.EmailInfo;
@@ -29,12 +27,17 @@
 import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
@@ -51,6 +54,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -65,7 +69,6 @@
 
 /** Set a user's account settings. * */
 @CommandMetaData(name = "set-account", description = "Change an account's settings")
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 final class SetAccountCommand extends SshCommand {
 
   @Argument(
@@ -117,9 +120,12 @@
   @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
   private boolean clearHttpPassword;
 
+  @Option(name = "--generate-http-password", usage = "generate a new HTTP password for the account")
+  private boolean generateHttpPassword;
+
   @Inject private IdentifiedUser.GenericFactory genericUserFactory;
 
-  @Inject private CreateEmail.Factory createEmailFactory;
+  @Inject private CreateEmail createEmail;
 
   @Inject private GetEmails getEmails;
 
@@ -141,20 +147,54 @@
 
   @Inject private DeleteSshKey deleteSshKey;
 
+  @Inject private PermissionBackend permissionBackend;
+
+  @Inject private Provider<CurrentUser> userProvider;
+
   private AccountResource rsrc;
 
   @Override
   public void run() throws Exception {
+    user = genericUserFactory.create(id);
+
     validate();
     setAccount();
   }
 
   private void validate() throws UnloggedFailure {
-    if (active && inactive) {
-      throw die("--active and --inactive options are mutually exclusive.");
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canModifyAccount =
+        isAdmin || userPermission.testOrFalse(GlobalPermission.MODIFY_ACCOUNT);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canModifyAccount) {
+      throw die(
+          "Setting another user's account information requries 'modify account' or 'administrate server' capabilities.");
     }
-    if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw die("--http-password and --clear-http-password options are mutually exclusive.");
+    if (active || inactive) {
+      if (!canModifyAccount) {
+        throw die(
+            "--active and --inactive require 'modify account' or 'administrate server' capabilities.");
+      }
+      if (active && inactive) {
+        throw die("--active and --inactive options are mutually exclusive.");
+      }
+    }
+
+    if (generateHttpPassword && clearHttpPassword) {
+      throw die("--generate-http-password and --clear-http-password are mutually exclusive.");
+    }
+    if (!Strings.isNullOrEmpty(httpPassword)) { // gave --http-password
+      if (!isAdmin) {
+        throw die("--http-password requires 'administrate server' capabilities.");
+      }
+      if (generateHttpPassword) {
+        throw die("--http-password and --generate-http-password options are mutually exclusive.");
+      }
+      if (clearHttpPassword) {
+        throw die("--http-password and --clear-http-password options are mutually exclusive.");
+      }
     }
     if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
       throw die("Only one option may use the stdin");
@@ -196,10 +236,16 @@
         putName.apply(rsrc, in);
       }
 
-      if (httpPassword != null || clearHttpPassword) {
+      if (httpPassword != null || clearHttpPassword || generateHttpPassword) {
         HttpPasswordInput in = new HttpPasswordInput();
         in.httpPassword = httpPassword;
-        putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          in.generate = true;
+        }
+        Response<String> resp = putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          stdout.print("New password: " + resp.value() + "\n");
+        }
       }
 
       if (active) {
@@ -269,7 +315,7 @@
     in.email = email;
     in.noConfirmation = true;
     try {
-      createEmailFactory.create(email).apply(rsrc, in);
+      createEmail.apply(rsrc, IdString.fromDecoded(email), in);
     } catch (EmailException e) {
       throw die(e.getMessage());
     }
diff --git a/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
similarity index 66%
rename from java/com/google/gerrit/sshd/commands/AdminSetParent.java
rename to java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 67ed098..56ee371 100644
--- a/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -16,41 +16,36 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.server.restapi.project.SetParent;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(
     name = "set-project-parent",
     description = "Change the project permissions are inherited from")
-final class AdminSetParent extends SshCommand {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+final class SetParentCommand extends SshCommand {
   @Option(
       name = "--parent",
       aliases = {"-p"},
@@ -80,14 +75,18 @@
 
   @Inject private ProjectCache projectCache;
 
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private AllProjectsName allProjectsName;
-
   @Inject private ListChildProjects listChildProjects;
 
+  @Inject private SetParent setParent;
+
   private Project.NameKey newParentKey;
 
+  private static ParentInput parentInput(String parent) {
+    ParentInput input = new ParentInput();
+    input.parent = parent;
+    return input;
+  }
+
   @Override
   protected void run() throws Failure {
     if (oldParent == null && children.isEmpty()) {
@@ -100,25 +99,9 @@
     }
 
     final StringBuilder err = new StringBuilder();
-    final Set<Project.NameKey> grandParents = new HashSet<>();
-
-    grandParents.add(allProjectsName);
 
     if (newParent != null) {
       newParentKey = newParent.getProject().getNameKey();
-
-      // Catalog all grandparents of the "parent", we want to
-      // catch a cycle in the parent pointers before it occurs.
-      //
-      Project.NameKey gp = newParent.getProject().getParent();
-      while (gp != null && grandParents.add(gp)) {
-        final ProjectState s = projectCache.get(gp);
-        if (s != null) {
-          gp = s.getProject().getParent();
-        } else {
-          break;
-        }
-      }
     }
 
     final List<Project.NameKey> childProjects =
@@ -135,47 +118,19 @@
 
     for (Project.NameKey nameKey : childProjects) {
       final String name = nameKey.get();
-
-      if (allProjectsName.equals(nameKey)) {
-        // Don't allow the wild card project to have a parent.
-        //
-        err.append("error: Cannot set parent of '").append(name).append("'\n");
-        continue;
-      }
-
-      if (grandParents.contains(nameKey) || nameKey.equals(newParentKey)) {
-        // Try to avoid creating a cycle in the parent pointers.
-        //
-        err.append("error: Cycle exists between '")
-            .append(name)
-            .append("' and '")
-            .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
-            .append("'\n");
-        continue;
-      }
-
-      try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-        ProjectConfig config = ProjectConfig.read(md);
-        config.getProject().setParentName(newParentKey);
-        md.setMessage(
-            "Inherit access from "
-                + (newParentKey != null ? newParentKey.get() : allProjectsName.get())
-                + "\n");
-        config.commit(md);
-      } catch (RepositoryNotFoundException notFound) {
-        err.append("error: Project ").append(name).append(" not found\n");
-      } catch (IOException | ConfigInvalidException e) {
-        final String msg = "Cannot update project " + name;
-        logger.atSevere().withCause(e).log(msg);
-        err.append("error: ").append(msg).append("\n");
-      }
-
+      ProjectState project = projectCache.get(nameKey);
       try {
-        projectCache.evict(nameKey);
-      } catch (IOException e) {
-        final String msg = "Cannot reindex project: " + name;
-        logger.atSevere().withCause(e).log(msg);
-        err.append("error: ").append(msg).append("\n");
+        setParent.apply(new ProjectResource(project, user), parentInput(newParentKey.get()));
+      } catch (AuthException e) {
+        err.append("error: insuffient access rights to change parent of '")
+            .append(name)
+            .append("'\n");
+      } catch (ResourceConflictException | ResourceNotFoundException | BadRequestException e) {
+        err.append("error: ").append(e.getMessage()).append("'\n");
+      } catch (UnprocessableEntityException | IOException e) {
+        throw new Failure(1, "failure in request", e);
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
       }
     }
 
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index b30799b..baadf02 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -15,14 +15,16 @@
 package com.google.gerrit.sshd.commands;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.ioutil.HexFormat;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -33,11 +35,7 @@
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.Date;
-import java.util.List;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -92,25 +90,27 @@
       throw new Failure(1, "fatal: sshd no longer running");
     }
 
-    final List<IoSession> list = new ArrayList<>(acceptor.getManagedSessions().values());
-    Collections.sort(
-        list,
-        new Comparator<IoSession>() {
-          @Override
-          public int compare(IoSession arg0, IoSession arg1) {
-            if (arg0 instanceof MinaSession) {
-              MinaSession mArg0 = (MinaSession) arg0;
-              MinaSession mArg1 = (MinaSession) arg1;
-              if (mArg0.getSession().getCreationTime() < mArg1.getSession().getCreationTime()) {
-                return -1;
-              } else if (mArg0.getSession().getCreationTime()
-                  > mArg1.getSession().getCreationTime()) {
-                return 1;
-              }
-            }
-            return (int) (arg0.getId() - arg1.getId());
-          }
-        });
+    final ImmutableList<IoSession> list =
+        acceptor
+            .getManagedSessions()
+            .values()
+            .stream()
+            .sorted(
+                (arg0, arg1) -> {
+                  if (arg0 instanceof MinaSession) {
+                    MinaSession mArg0 = (MinaSession) arg0;
+                    MinaSession mArg1 = (MinaSession) arg1;
+                    if (mArg0.getSession().getCreationTime()
+                        < mArg1.getSession().getCreationTime()) {
+                      return -1;
+                    } else if (mArg0.getSession().getCreationTime()
+                        > mArg1.getSession().getCreationTime()) {
+                      return 1;
+                    }
+                  }
+                  return (int) (arg0.getId() - arg1.getId());
+                })
+            .collect(toImmutableList());
 
     hostNameWidth = wide ? Integer.MAX_VALUE : columns - 9 - 9 - 10 - 32;
 
@@ -168,7 +168,7 @@
   }
 
   private static String id(SshSession sd) {
-    return sd != null ? IdGenerator.format(sd.getSessionId()) : "";
+    return sd != null ? HexFormat.fromInt(sd.getSessionId()) : "";
   }
 
   private static String time(long now, long time) {
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index c97372c..ffd98d5 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -151,6 +151,7 @@
     stdout = toPrintWriter(out);
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event event) {
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 43aa978..15ceb77 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -19,14 +19,17 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//lib:guava",
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index b0229c3..9e45b7c 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gerrit.server.logging.LoggingContext;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -105,11 +106,13 @@
  */
 public class ConfigSuite extends Suite {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   static {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+    System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 
   public static final String DEFAULT = "default";
@@ -156,7 +159,7 @@
 
     @Override
     public Object createTest() throws Exception {
-      Object test = getTestClass().getJavaClass().newInstance();
+      Object test = getTestClass().getJavaClass().getDeclaredConstructor().newInstance();
       parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
       if (nameField != null) {
         nameField.set(test, name);
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index 28946dc..e81d0f4 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -22,10 +22,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b3f6222..8f9aa14 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -178,6 +179,7 @@
     install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
+    install(new AuditModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
diff --git a/java/com/google/gerrit/truth/ListSubject.java b/java/com/google/gerrit/truth/ListSubject.java
index cccf51b..bd9df30 100644
--- a/java/com/google/gerrit/truth/ListSubject.java
+++ b/java/com/google/gerrit/truth/ListSubject.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.truth;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
@@ -46,7 +47,7 @@
     isNotNull();
     List<E> list = getActualList();
     if (index >= list.size()) {
-      fail("has an element at index " + index);
+      failWithoutActual(fact("expected to have element at index", index));
     }
     return elementAssertThatFunction.apply(list.get(index));
   }
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
index f24b5da..d91f07b 100644
--- a/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.truth;
 
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.DefaultSubject;
@@ -57,7 +58,7 @@
     isNotNull();
     Optional<T> optional = actual();
     if (!optional.isPresent()) {
-      fail("has a value");
+      failWithoutActual(fact("expected to have", "value"));
     }
   }
 
@@ -65,7 +66,7 @@
     isNotNull();
     Optional<T> optional = actual();
     if (optional.isPresent()) {
-      fail("does not have a value");
+      failWithoutActual(fact("expected not to have", "value"));
     }
   }
 
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index 91c14f7..c94fc1d 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//lib:args4j",
         "//lib:guava",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index f2d07ed..231b335 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -34,10 +34,13 @@
 
 package com.google.gerrit.util.cli;
 
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.StringWriter;
@@ -75,6 +78,8 @@
  * from the GNU style format to the args4j style format prior to invoking args4j for parsing.
  */
 public class CmdLineParser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     CmdLineParser create(Object bean);
   }
@@ -233,6 +238,7 @@
   }
 
   public void parseOptionMap(ListMultimap<String, String> params) throws CmdLineException {
+    logger.atFinest().log("Command-line parameters: %s", params.keySet());
     List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
     for (String key : params.keySet()) {
       String name = makeOption(key);
@@ -320,12 +326,12 @@
       return false;
     }
 
-    throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
+    throw new CmdLineException(parser, localizable("invalid boolean \"%s=%s\""), name, value);
   }
 
   private static class PrefixedOption implements Option {
-    String prefix;
-    Option o;
+    private final String prefix;
+    private final Option o;
 
     PrefixedOption(String prefix, Option o) {
       this.prefix = prefix;
@@ -378,6 +384,16 @@
     }
 
     @Override
+    public String[] forbids() {
+      return null;
+    }
+
+    @Override
+    public boolean help() {
+      return false;
+    }
+
+    @Override
     public Class<? extends Annotation> annotationType() {
       return o.annotationType();
     }
@@ -563,9 +579,19 @@
     public boolean isMultiValued() {
       return false;
     }
+
+    @Override
+    public String[] forbids() {
+      return null;
+    }
+
+    @Override
+    public boolean help() {
+      return false;
+    }
   }
 
   public CmdLineException reject(String message) {
-    return new CmdLineException(parser, message);
+    return new CmdLineException(parser, localizable(message));
   }
 }
diff --git a/java/com/google/gerrit/util/cli/Localizable.java b/java/com/google/gerrit/util/cli/Localizable.java
new file mode 100644
index 0000000..33989d3
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/Localizable.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2018 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.util.cli;
+
+import java.util.Locale;
+
+public class Localizable implements org.kohsuke.args4j.Localizable {
+  private final String format;
+
+  @Override
+  public String formatWithLocale(Locale locale, Object... args) {
+    return String.format(locale, format, args);
+  }
+
+  @Override
+  public String format(Object... args) {
+    return String.format(format, args);
+  }
+
+  private Localizable(String format) {
+    this.format = format;
+  }
+
+  public static Localizable localizable(String format) {
+    return new Localizable(format);
+  }
+}
diff --git a/java/com/google/gwtexpui/server/CacheHeaders.java b/java/com/google/gerrit/util/http/CacheHeaders.java
similarity index 98%
rename from java/com/google/gwtexpui/server/CacheHeaders.java
rename to java/com/google/gerrit/util/http/CacheHeaders.java
index 0e5e425..454587c 100644
--- a/java/com/google/gwtexpui/server/CacheHeaders.java
+++ b/java/com/google/gerrit/util/http/CacheHeaders.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gwtexpui.server;
+package com.google.gerrit.util.http;
 
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.SECONDS;
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
index 1318125..20d093e 100644
--- a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
@@ -14,6 +14,9 @@
 
 package com.google.gwtexpui.globalkey.client;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
@@ -31,10 +34,7 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -228,15 +228,6 @@
   }
 
   private List<KeyCommand> sort(KeyCommandSet set) {
-    final List<KeyCommand> keys = new ArrayList<>(set.getKeys());
-    Collections.sort(
-        keys,
-        new Comparator<KeyCommand>() {
-          @Override
-          public int compare(KeyCommand arg0, KeyCommand arg1) {
-            return arg0.getHelpText().compareTo(arg1.getHelpText());
-          }
-        });
-    return keys;
+    return set.getKeys().stream().sorted(comparing(KeyCommand::getHelpText)).collect(toList());
   }
 }
diff --git a/java/com/google/gwtexpui/linker/BUILD b/java/com/google/gwtexpui/linker/BUILD
deleted file mode 100644
index 5c5c600..0000000
--- a/java/com/google/gwtexpui/linker/BUILD
+++ /dev/null
@@ -1,6 +0,0 @@
-java_library(
-    name = "server",
-    srcs = glob(["server/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
-)
diff --git a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index ef80cdb..758521f 100644
--- a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -14,11 +14,12 @@
 
 package com.google.gwtexpui.safehtml.client;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gwt.user.client.ui.SuggestOracle;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 /**
@@ -115,15 +116,8 @@
      * terms.
      */
     private static List<String> splitQuery(String query) {
-      List<String> queryTerms = Arrays.asList(query.split("\\s+"));
-      Collections.sort(
-          queryTerms,
-          new Comparator<String>() {
-            @Override
-            public int compare(String s1, String s2) {
-              return Integer.compare(s2.length(), s1.length());
-            }
-          });
+      List<String> queryTerms =
+          Arrays.stream(query.split("\\s+")).sorted(comparing(String::length)).collect(toList());
 
       List<String> result = new ArrayList<>();
       for (String s : queryTerms) {
diff --git a/java/com/google/gwtexpui/server/BUILD b/java/com/google/gwtexpui/server/BUILD
deleted file mode 100644
index 9b81564..0000000
--- a/java/com/google/gwtexpui/server/BUILD
+++ /dev/null
@@ -1,6 +0,0 @@
-java_library(
-    name = "server",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
-)
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index c2aaa76..1bfc95c 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -14,9 +14,12 @@
 
 package gerrit;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.rules.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
@@ -24,6 +27,8 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
 
 abstract class AbstractCommitUserIdentityPredicate extends Predicate.P3 {
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
@@ -36,7 +41,7 @@
     cont = n;
   }
 
-  protected Operation exec(Prolog engine, UserIdentity userId) throws PrologException {
+  protected Operation exec(Prolog engine, PersonIdent userId) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
     Term a2 = arg2.dereference();
@@ -46,7 +51,18 @@
     Term nameTerm = Prolog.Nil;
     Term emailTerm = Prolog.Nil;
 
-    Account.Id id = userId.getAccount();
+    PrologEnvironment env = (PrologEnvironment) engine.control;
+    Emails emails = env.getArgs().getEmails();
+    Account.Id id = null;
+    try {
+      ImmutableSet<Account.Id> ids = emails.getAccountForExternal(userId.getEmailAddress());
+      if (ids.size() == 1) {
+        id = ids.iterator().next();
+      }
+    } catch (IOException e) {
+      throw new SystemException(e.getMessage());
+    }
+
     if (id == null) {
       idTerm = anonymous;
     } else {
@@ -58,7 +74,7 @@
       nameTerm = SymbolTerm.create(name);
     }
 
-    String email = userId.getEmail();
+    String email = userId.getEmailAddress();
     if (email != null && !email.equals("")) {
       emailTerm = SymbolTerm.create(email);
     }
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 4644af87..8281d8e 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -11,5 +11,6 @@
         "//lib/flogger:api",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
+        "@guava//jar",
     ],
 )
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index a876b5e..998b30e 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity author = psInfo.getAuthor();
-    return exec(engine, author);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getAuthorIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index b24b004..293d8ce 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity committer = psInfo.getCommitter();
-    return exec(engine, committer);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getCommitterIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index 05bb4bb..eb996d6 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -21,6 +21,7 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Returns the commit message as a symbol
@@ -40,7 +41,8 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    String commitMessage = StoredValues.COMMIT_MESSAGE.get(engine);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    String commitMessage = revCommit.getFullMessage();
 
     SymbolTerm msg = SymbolTerm.create(commitMessage);
     if (!a1.unify(msg, engine.trail)) {
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index 3c7b966..bf387fd 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -39,7 +39,7 @@
 
   @Test
   public void universalGroupBackendHandlesTestGroup() throws Exception {
-    RegistrationHandle registrationHandle = groupBackends.add(testGroupBackend);
+    RegistrationHandle registrationHandle = groupBackends.add("gerrit", testGroupBackend);
     try {
       assertThat(universalGroupBackend.handles(testUUID)).isTrue();
     } finally {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f17165e..1eaadca 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
@@ -62,16 +64,21 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -89,7 +96,9 @@
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -110,7 +119,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
@@ -229,7 +237,7 @@
   @Before
   public void addAccountIndexEventCounter() {
     accountIndexedCounter = new AccountIndexedCounter();
-    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+    accountIndexEventCounterHandle = accountIndexedListeners.add("gerrit", accountIndexedCounter);
   }
 
   @After
@@ -242,7 +250,7 @@
   @Before
   public void addRefUpdateCounter() {
     refUpdateCounter = new RefUpdateCounter();
-    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+    refUpdateCounterHandle = refUpdateListeners.add("gerrit", refUpdateCounter);
   }
 
   @After
@@ -300,7 +308,7 @@
 
   @Test
   public void createByAccountCreator() throws Exception {
-    Account.Id accountId = createByAccountCreator(2); // account creation + external ID creation
+    Account.Id accountId = createByAccountCreator(1);
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
         RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
@@ -339,7 +347,7 @@
     assertThat(accountInfo.status).isNull();
 
     Account.Id accountId = new Account.Id(accountInfo._accountId);
-    accountIndexedCounter.assertReindexOf(accountId, 2); // account creation + external ID creation
+    accountIndexedCounter.assertReindexOf(accountId, 1);
     assertThat(externalIds.byAccount(accountId))
         .containsExactly(
             ExternalId.createUsername(input.username, accountId, null),
@@ -525,12 +533,13 @@
 
   @Test
   public void validateAccountActivation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount activatableAccount =
+    Account.Id activatableAccountId =
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-    com.google.gerrit.acceptance.testsuite.account.TestAccount deactivatableAccount =
+    Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
     RegistrationHandle registrationHandle =
         accountActivationValidationListeners.add(
+            "gerrit",
             new AccountActivationValidationListener() {
               @Override
               public void validateActivation(AccountState account) throws ValidationException {
@@ -552,61 +561,56 @@
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
 
       // Activate account that can be activated
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Activate account that is already active
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Try deactivating account that cannot be deactivated
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to deactive account");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isTrue();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
 
       // Deactivate account that can be deactivated
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Try activating account that cannot be activated
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to active account");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
     } finally {
       registrationHandle.remove();
     }
@@ -728,6 +732,17 @@
   }
 
   @Test
+  public void deleteStarLabelsFromChangeWithoutStarLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+
+    gApi.accounts().self().setStars(triplet, new StarsInput());
+
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+  }
+
+  @Test
   public void starWithDefaultAndIgnoreLabel() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -808,6 +823,58 @@
   }
 
   @Test
+  public void getOwnDetail() throws Exception {
+    String email = "preferred@example.com";
+    String name = "Foo";
+    String username = name("foo");
+    TestAccount foo = accountCreator.create(username, email, name);
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id.get()).addEmail(input);
+
+    String status = "OOO";
+    gApi.accounts().id(foo.id.get()).setStatus(status);
+
+    setApiUser(foo);
+    AccountDetailInfo detail = gApi.accounts().id(foo.id.get()).detail();
+    assertThat(detail._accountId).isEqualTo(foo.id.get());
+    assertThat(detail.name).isEqualTo(name);
+    assertThat(detail.username).isEqualTo(username);
+    assertThat(detail.email).isEqualTo(email);
+    assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
+    assertThat(detail.status).isEqualTo(status);
+    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.getId()).getRegisteredOn());
+    assertThat(detail.inactive).isNull();
+    assertThat(detail._moreAccounts).isNull();
+  }
+
+  @Test
+  public void detailOfOtherAccountDoesntIncludeSecondaryEmailsWithoutModifyAccount()
+      throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id.get()).addEmail(input);
+
+    setApiUser(user);
+    AccountDetailInfo detail = gApi.accounts().id(foo.id.get()).detail();
+    assertThat(detail.secondaryEmails).isNull();
+  }
+
+  @Test
+  public void detailOfOtherAccountIncludeSecondaryEmailsWithModifyAccount() throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id.get()).addEmail(input);
+
+    AccountDetailInfo detail = gApi.accounts().id(foo.id.get()).detail();
+    assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
+  }
+
+  @Test
   public void getOwnEmails() throws Exception {
     String email = "preferred@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
@@ -2104,6 +2171,38 @@
   }
 
   @Test
+  public void createUserWithValidUsername() throws Exception {
+    ImmutableList<String> names =
+        ImmutableList.of(
+            "user@domain",
+            "user-name",
+            "user_name",
+            "1234",
+            "user1234",
+            "1234@domain",
+            "user!+alias{*}#$%&’^=~|@domain");
+    for (String name : names) {
+      gApi.accounts().create(name);
+    }
+  }
+
+  @Test
+  public void createUserWithInvalidUsername() throws Exception {
+    ImmutableList<String> invalidNames =
+        ImmutableList.of(
+            "@", "@foo", "-", "-foo", "_", "_foo", "!", "+", "{", "}", "*", "%", "#", "$", "&", "’",
+            "^", "=", "~");
+    for (String name : invalidNames) {
+      try {
+        gApi.accounts().create(name);
+        fail(String.format("Expected BadRequestException for username [%s]", name));
+      } catch (BadRequestException e) {
+        assertThat(e).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
+      }
+    }
+  }
+
+  @Test
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
     accountOperations.newAccount().username(username).create();
@@ -2444,7 +2543,7 @@
     // Manually inserting/updating/deleting an external ID of the user makes the index document
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
 
       ExternalId.Key key = ExternalId.Key.create("foo", "foo");
       extIdNotes.insert(ExternalId.create(key, accountId));
@@ -2491,6 +2590,160 @@
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
   }
 
+  @Test
+  public void deleteAllDraftComments() throws Exception {
+    try {
+      TestTimeUtil.resetWithClockStep(1, SECONDS);
+      Project.NameKey project2 = createProject("project2");
+      PushOneCommit.Result r1 = createChange();
+
+      TestRepository<?> tr2 = cloneProject(project2);
+      PushOneCommit.Result r2 =
+          createChange(
+              tr2,
+              "refs/heads/master",
+              "Change in project2",
+              PushOneCommit.FILE_NAME,
+              "content2",
+              null);
+
+      // Create 2 drafts each on both changes for user.
+      setApiUser(user);
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft 1a");
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft 1b");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft 2a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft 2b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(2);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(2);
+
+      // Create 1 draft on first change for admin.
+      setApiUser(admin);
+      createDraft(r1, PushOneCommit.FILE_NAME, "admin draft");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      // Delete user's draft comments; leave admin's alone.
+      setApiUser(user);
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
+
+      // Results are ordered according to the change search, most recently updated first.
+      assertThat(result).hasSize(2);
+      DeletedDraftCommentInfo del2 = result.get(0);
+      assertThat(del2.change.changeId).isEqualTo(r2.getChangeId());
+      assertThat(del2.deleted.stream().map(c -> c.message)).containsExactly("draft 2a", "draft 2b");
+      DeletedDraftCommentInfo del1 = result.get(1);
+      assertThat(del1.change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(del1.deleted.stream().map(c -> c.message)).containsExactly("draft 1a", "draft 1b");
+
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).isEmpty();
+
+      setApiUser(admin);
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteDraftCommentsByQuery() throws Exception {
+    try {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts()
+              .self()
+              .deleteDraftComments(new DeleteDraftCommentsInput("change:" + r1.getChangeId()));
+      assertThat(result).hasSize(1);
+      assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
+
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteOtherUsersDraftCommentsDisallowed() throws Exception {
+    try {
+      PushOneCommit.Result r = createChange();
+      setApiUser(user);
+      createDraft(r, PushOneCommit.FILE_NAME, "draft");
+      setApiUser(admin);
+      try {
+        gApi.accounts().id(user.id.get()).deleteDraftComments(new DeleteDraftCommentsInput());
+        assert_().fail("expected AuthException");
+      } catch (AuthException e) {
+        assertThat(e).hasMessageThat().isEqualTo("Cannot delete drafts of other user");
+      }
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  @Test
+  public void deleteDraftCommentsSkipsInvisibleChanges() throws Exception {
+    try {
+      createBranch(new Branch.NameKey(project, "secret"));
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange("refs/for/secret");
+
+      setApiUser(user);
+      createDraft(r1, PushOneCommit.FILE_NAME, "draft a");
+      createDraft(r2, PushOneCommit.FILE_NAME, "draft b");
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+
+      block(project, "refs/heads/secret", Permission.READ, REGISTERED_USERS);
+      List<DeletedDraftCommentInfo> result =
+          gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
+      assertThat(result).hasSize(1);
+      assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
+      assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
+
+      removePermission(project, "refs/heads/secret", Permission.READ);
+      assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
+      // Draft still exists since change wasn't visible when drafts where deleted.
+      assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
+    } finally {
+      cleanUpDrafts();
+    }
+  }
+
+  private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
+    DraftInput in = new DraftInput();
+    in.path = path;
+    in.line = 1;
+    in.message = message;
+    gApi.changes().id(r.getChangeId()).current().createDraft(in);
+  }
+
+  private void cleanUpDrafts() throws Exception {
+    for (TestAccount testAccount : accountCreator.getAll()) {
+      setApiUser(testAccount);
+      for (ChangeInfo changeInfo : gApi.changes().query("has:draft").get()) {
+        for (CommentInfo c :
+            gApi.changes()
+                .id(changeInfo.id)
+                .drafts()
+                .values()
+                .stream()
+                .flatMap(List::stream)
+                .collect(toImmutableList())) {
+          gApi.changes().id(changeInfo.id).revision(c.patchSet).draft(c.id).delete();
+        }
+      }
+    }
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return new Correspondence<GroupInfo, String>() {
       @Override
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index ef8451d..60a61d1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -162,7 +162,7 @@
       md.getCommitBuilder().setAuthor(ident);
       md.getCommitBuilder().setCommitter(ident);
 
-      AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+      AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
       accountConfig.setAccountUpdate(accountUpdate);
       accountConfig.commit(md);
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index c3a0dc9..7a4a901 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -177,7 +177,7 @@
     setApiUser(user);
     setUseContributorAgreements(InheritableBoolean.TRUE);
     exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
+    exception.expectMessage("Contributor Agreement");
     gApi.changes().id(change.changeId).revert();
   }
 
@@ -209,7 +209,7 @@
     in.destination = dest.ref;
     in.message = change.subject;
     exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
+    exception.expectMessage("Contributor Agreement");
     gApi.changes().id(change.changeId).current().cherryPick(in);
   }
 
@@ -227,7 +227,7 @@
       gApi.changes().create(newChangeInput());
       fail("Expected AuthException");
     } catch (AuthException e) {
-      assertThat(e.getMessage()).contains("A Contributor Agreement must be completed");
+      assertThat(e.getMessage()).contains("Contributor Agreement");
     }
 
     // Sign the agreement
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index b52efe7..9bc30ba 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -8,4 +8,5 @@
         "noci",
         "no_windows",
     ],
+    deps = ["//java/com/google/gerrit/mail"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index b85e2f2..88803ec 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -44,6 +44,7 @@
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.Util.category;
 import static com.google.gerrit.server.project.testing.Util.value;
@@ -58,16 +59,15 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
-import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelFunction;
@@ -91,6 +91,8 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Comment.Range;
@@ -123,6 +125,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -137,7 +140,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.PostReview;
@@ -200,7 +202,7 @@
   @Before
   public void addChangeIndexedCounter() {
     changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
   }
 
   @After
@@ -211,16 +213,6 @@
   }
 
   @Test
-  public void reflog() throws Exception {
-    // Tests are using DfsRepository which does not implement getReflogReader,
-    // so this will always fail.
-    // TODO: change this if/when DfsRepository#getReflogReader is implemented.
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("reflog not supported");
-    gApi.projects().name(project.get()).branch("master").reflog();
-  }
-
-  @Test
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -433,6 +425,29 @@
   }
 
   @Test
+  public void setWorkInProgressAllowedAsProjectOwner() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+
+    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user2);
+    gApi.changes().id(changeId).setWorkInProgress();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(input);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
   public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result rready = createChange();
     String changeId = rready.getChangeId();
@@ -457,6 +472,20 @@
   }
 
   @Test
+  public void setReadyForReviewAllowedAsProjectOwner() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user2);
+    gApi.changes().id(changeId).setReadyForReview();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+  }
+
+  @Test
   public void hasReviewStarted() throws Exception {
     PushOneCommit.Result r = createWorkInProgressChange();
     String changeId = r.getChangeId();
@@ -972,12 +1001,7 @@
 
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
+    deleteChangeAsUser(admin, admin);
   }
 
   @Test
@@ -994,41 +1018,87 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteChangeAsUserWithDeleteOwnChangesPermission() throws Exception {
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
+    allow("refs/*", Permission.DELETE_CHANGES, REGISTERED_USERS);
+    deleteChangeAsUser(admin, user);
+  }
+
+  @Test
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("delete-change"));
+    groupApi.addMembers("user");
+
+    ProjectInput in = new ProjectInput();
+    in.name = name("delete-change");
+    in.owners = Lists.newArrayListWithCapacity(1);
+    in.owners.add(groupApi.name());
+    in.createEmptyCommit = true;
+    ProjectApi api = gApi.projects().create(in);
+
+    Project.NameKey nameKey = new Project.NameKey(api.get().name);
+
+    try (ProjectConfigUpdate u = updateProject(nameKey)) {
+      Util.allow(u.getConfig(), Permission.DELETE_CHANGES, PROJECT_OWNERS, "refs/*");
+      u.save();
+    }
+
+    deleteChangeAsUser(nameKey, admin, user);
+  }
+
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
     allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    deleteChangeAsUser(user, user);
+  }
 
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, CHANGE_OWNER);
+    deleteChangeAsUser(user, user);
+  }
+
+  private void deleteChangeAsUser(
+      com.google.gerrit.acceptance.TestAccount owner,
+      com.google.gerrit.acceptance.TestAccount deleteAs)
+      throws Exception {
+    deleteChangeAsUser(project, owner, deleteAs);
+  }
+
+  private void deleteChangeAsUser(
+      Project.NameKey projectName,
+      com.google.gerrit.acceptance.TestAccount owner,
+      com.google.gerrit.acceptance.TestAccount deleteAs)
+      throws Exception {
     try {
-      PushOneCommit.Result changeResult =
-          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-      String changeId = changeResult.getChangeId();
-      int id = changeResult.getChange().getId().id;
-      RevCommit commit = changeResult.getCommit();
+      setApiUser(owner);
+      ChangeInput in = new ChangeInput();
+      in.project = projectName.get();
+      in.branch = "refs/heads/master";
+      in.subject = "test";
+      ChangeInfo changeInfo = gApi.changes().create(in).get();
+      String changeId = changeInfo.changeId;
+      int id = changeInfo._number;
+      String commit = changeInfo.currentRevision;
 
-      setApiUser(user);
+      assertThat(gApi.changes().id(changeId).info().owner._accountId).isEqualTo(owner.id.get());
+
+      setApiUser(deleteAs);
       gApi.changes().id(changeId).delete();
 
       assertThat(query(changeId)).isEmpty();
 
       String ref = new Change.Id(id).toRefPrefix() + "1";
-      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
+      eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
+      eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email);
     } finally {
       removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      removePermission(project, "refs/*", Permission.DELETE_CHANGES);
     }
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
   public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    changeResult.assertOkStatus();
-    String changeId = changeResult.getChangeId();
-
-    setApiUser(admin);
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
+    deleteChangeAsUser(user, admin);
   }
 
   @Test
@@ -1618,7 +1688,7 @@
     // create a group named "ab" with one user: testUser
     String email = "abcd@test.com";
     String fullname = "abcd";
-    TestAccount testUser =
+    Account.Id accountIdOfTestUser =
         accountOperations
             .newAccount()
             .username("abcd")
@@ -1651,7 +1721,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1678,7 +1748,7 @@
 
     String myGroupUserEmail = "lee@test.com";
     String myGroupUserFullname = "lee";
-    TestAccount myGroupUser =
+    Account.Id accountIdOfGroupUser =
         accountOperations
             .newAccount()
             .username("lee")
@@ -1715,7 +1785,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -2145,13 +2215,12 @@
 
     // notify unrelated account as TO
     String email = "user2@example.com";
-    TestAccount user2 =
-        accountOperations
-            .newAccount()
-            .username("user2")
-            .preferredEmail(email)
-            .fullname("User2")
-            .create();
+    accountOperations
+        .newAccount()
+        .username("user2")
+        .preferredEmail(email)
+        .fullname("User2")
+        .create();
     setApiUser(user);
     recommend(r.getChangeId());
     setApiUser(admin);
@@ -2159,7 +2228,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyTo(user2);
+    assertNotifyTo(email, "User2");
 
     // notify unrelated account as CC
     setApiUser(user);
@@ -2169,7 +2238,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyCc(user2);
+    assertNotifyCc(email, "User2");
 
     // notify unrelated account as BCC
     setApiUser(user);
@@ -2179,7 +2248,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyBcc(user2);
+    assertNotifyBcc(email, "User2");
   }
 
   @Test
@@ -2529,6 +2598,7 @@
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
@@ -2676,9 +2746,7 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.updated, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -2686,10 +2754,7 @@
 
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
-      expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+      expectedAuthor = changeNoteUtil.newIdent(getAccount(admin.id), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3998,38 +4063,6 @@
     }
   }
 
-  private static class ChangeIndexedCounter implements ChangeIndexedListener {
-    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
-
-    @Override
-    public void onChangeIndexed(String projectName, int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    @Override
-    public void onChangeDeleted(int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByChange.clear();
-    }
-
-    long getCount(ChangeInfo info) {
-      return countsByChange.get(info._number);
-    }
-
-    void assertReindexOf(ChangeInfo info) {
-      assertReindexOf(info, 1);
-    }
-
-    void assertReindexOf(ChangeInfo info, int expectedCount) {
-      assertThat(getCount(info)).isEqualTo(expectedCount);
-      assertThat(countsByChange).hasSize(1);
-      clear();
-    }
-  }
-
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 4e3f048..d6be960 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -183,24 +183,23 @@
   @Test
   public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
     String username = name("user");
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().username(username).create();
+    Account.Id accountId = accountOperations.newAccount().username(username).create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
     String groupName = createGroup("users");
     AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
 
     gApi.groups().id(groupName).addMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
 
     gApi.groups().id(groupName).removeMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
   }
 
@@ -411,19 +410,17 @@
 
   @Test
   public void cachedGroupsForMemberAreUpdatedOnGroupCreation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().create();
+    Account.Id accountId = accountOperations.newAccount().create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("Users");
-    groupInput.members = ImmutableList.of(String.valueOf(account.accountId().get()));
+    groupInput.members = ImmutableList.of(String.valueOf(accountId.get()));
     GroupInfo group = gApi.groups().create(groupInput).get();
 
-    Collection<AccountGroup.UUID> groups =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
   }
 
@@ -1298,7 +1295,7 @@
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
     RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add(groupIndexedCounter);
+        groupIndexedListeners.add("gerrit", groupIndexedCounter);
     try {
       // Running the reindexer right after startup should not need to reindex any group since
       // reindexing was already done on startup.
@@ -1355,7 +1352,7 @@
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
     RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add(groupIndexedCounter);
+        groupIndexedListeners.add("gerrit", groupIndexedCounter);
     try {
       // No group indexing happened on startup. All groups should be reindexed now.
       slaveGroupIndexer.run();
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index ee0463e..27f737f 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -108,6 +108,9 @@
     assertThat(api.get().disabled).isNull();
     assertPlugins(list().get(), PLUGINS);
 
+    // Using deprecated input
+    deprecatedInput();
+
     // Non-admin cannot disable
     setApiUser(user);
     try {
@@ -118,6 +121,15 @@
     }
   }
 
+  @SuppressWarnings("deprecation")
+  private void deprecatedInput() throws Exception {
+    com.google.gerrit.extensions.common.InstallPluginInput input =
+        new com.google.gerrit.extensions.common.InstallPluginInput();
+    input.raw = JS_PLUGIN_CONTENT;
+    gApi.plugins().install("legacy.html", input);
+    gApi.plugins().name("legacy").get();
+  }
+
   @Test
   public void installNotAllowed() throws Exception {
     exception.expect(MethodNotAllowedException.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/BUILD b/javatests/com/google/gerrit/acceptance/api/project/BUILD
index 768c20b..97c6f33 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/project/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*IT.java"]),
     group = "api_project",
     labels = ["api"],
+    deps = ["//java/com/google/gerrit/index/project"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 2b1416a..e4194a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -20,16 +20,18 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -39,35 +41,29 @@
 
 public class CheckAccessIT extends AbstractDaemonTest {
 
+  @Inject private GroupOperations groupOperations;
+
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
   private Project.NameKey secretRefProject;
   private TestAccount privilegedUser;
-  private InternalGroup privilegedGroup;
 
   @Before
   public void setUp() throws Exception {
     normalProject = createProject("normal");
     secretProject = createProject("secret");
     secretRefProject = createProject("secretRef");
-    privilegedGroup = group(createGroup("privilegedGroup"));
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
 
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
-    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id).update();
 
-    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
-        .contains("snowden");
-
-    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroupUuid);
     block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
 
     deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        false,
-        privilegedGroup.getGroupUUID());
+    grant(secretRefProject, "refs/heads/secret/*", Permission.READ, false, privilegedGroupUuid);
     block(
         secretRefProject,
         "refs/heads/secret/*",
@@ -81,13 +77,8 @@
         SystemGroupBackend.REGISTERED_USERS);
 
     // Ref permission
-    grant(
-        normalProject,
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        privilegedGroup.getGroupUUID());
-    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroup.getGroupUUID());
+    grant(normalProject, "refs/*", Permission.VIEW_PRIVATE_CHANGES, false, privilegedGroupUuid);
+    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroupUuid);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
new file mode 100644
index 0000000..6c6ad3d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -0,0 +1,314 @@
+// Copyright (C) 2018 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.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckProjectIT extends AbstractDaemonTest {
+  private TestRepository<InMemoryRepository> serverSideTestRepo;
+
+  @Before
+  public void setUp() throws Exception {
+    serverSideTestRepo =
+        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
+  }
+
+  @Test
+  public void noProblem() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(testRepo.getRevWalk().parseCommit(commit));
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toList()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(commit);
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void maxCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.maxCommits = 2;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void skipCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.skipCommits = 1;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noBranch() throws Exception {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branch is required");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void nonExistingBranch() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("branch 'non-existing' not found");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void branchPrefixCanBeOmitted() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("master");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void setLimitForMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT;
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void tooLargeMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT + 1;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "max commits can at most be set to "
+            + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    RevCommit commit =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message("A change")
+            .author(admin.getIdent())
+            .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()))
+            .create();
+    pushHead(testRepo, "refs/for/master");
+    return commit;
+  }
+
+  private static CheckProjectInput checkProjectInputForAutoCloseableCheck(String branch) {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+    input.autoCloseableChangesCheck.branch = branch;
+    return input;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index b4a05fc..18888ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,11 +15,19 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -39,7 +47,9 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.index.IndexExecutor;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -52,13 +62,17 @@
 public class ProjectIT extends AbstractDaemonTest {
   @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
 
+  @Inject
+  @IndexExecutor(BATCH)
+  private ListeningExecutorService executor;
+
   private ProjectIndexedCounter projectIndexedCounter;
   private RegistrationHandle projectIndexedCounterHandle;
 
   @Before
   public void addProjectIndexedCounter() {
     projectIndexedCounter = new ProjectIndexedCounter();
-    projectIndexedCounterHandle = projectIndexedListeners.add(projectIndexedCounter);
+    projectIndexedCounterHandle = projectIndexedListeners.add("gerrit", projectIndexedCounter);
   }
 
   @After
@@ -359,7 +373,7 @@
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("set HEAD not permitted for refs/heads/test");
+    exception.expectMessage("not permitted: set HEAD on refs/heads/test");
     gApi.projects().name(project.get()).head("test");
   }
 
@@ -381,6 +395,205 @@
     }
   }
 
+  @Test
+  public void reindexProject() throws Exception {
+    createProject("child", project);
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(allProjects.get()).index(false);
+    projectIndexedCounter.assertReindexOf(allProjects.get());
+  }
+
+  @Test
+  public void reindexProjectWithChildren() throws Exception {
+    Project.NameKey middle = createProject("middle", project);
+    Project.NameKey leave = createProject("leave", middle);
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(project.get()).index(true);
+    projectIndexedCounter.assertReindexExactly(
+        ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
+  }
+
+  @Test
+  public void maxObjectSizeIsNotSetByDefault() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeCanBeSetAndCleared() throws Exception {
+    // Set a value
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    // Clear the value
+    info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeIsInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(INHERITED_FROM_PARENT, project));
+  }
+
+  @Test
+  public void maxObjectSizeIsNotInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenNotSetOnParent() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideParentProjectWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(OVERRIDDEN_BY_PARENT, project));
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeIsInheritedFromGlobalConfig() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "300k")
+  public void inheritedMaxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideGlobalConfigWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("300k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("300k");
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+  }
+
+  @Test
+  public void invalidMaxObjectSizeIsRejected() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("100 foo");
+    setMaxObjectSize("100 foo");
+  }
+
+  private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
+    return gApi.projects().name(name.get()).config(input);
+  }
+
+  private ConfigInfo getConfig(Project.NameKey name) throws Exception {
+    return gApi.projects().name(name.get()).config();
+  }
+
+  private ConfigInfo getConfig() throws Exception {
+    return getConfig(project);
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -398,6 +611,16 @@
     return input;
   }
 
+  private ConfigInfo setMaxObjectSize(String value) throws Exception {
+    return setMaxObjectSize(project, value);
+  }
+
+  private ConfigInfo setMaxObjectSize(Project.NameKey name, String value) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.maxObjectSizeLimit = value;
+    return setConfig(name, input);
+  }
+
   private static class ProjectIndexedCounter implements ProjectIndexedListener {
     private final AtomicLongMap<String> countsByProject = AtomicLongMap.create();
 
@@ -427,5 +650,10 @@
     void assertNoReindex() {
       assertThat(countsByProject).isEmpty();
     }
+
+    void assertReindexExactly(ImmutableMap<String, Long> expected) {
+      assertThat(countsByProject.asMap()).containsExactlyEntriesIn(expected);
+      clear();
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
new file mode 100644
index 0000000..6fde012
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2018 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.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.project.ProjectIndexer;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.index.project.StalenessChecker;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.junit.Test;
+
+public class ProjectIndexerIT extends AbstractDaemonTest {
+  @Inject private ProjectIndexer projectIndexer;
+  @Inject private ProjectIndexCollection indexes;
+  @Inject private IndexConfig indexConfig;
+  @Inject private StalenessChecker stalenessChecker;
+
+  private static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+
+  @Test
+  public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
+    projectIndexer.index(project);
+    ProjectIndex i = indexes.getSearchIndex();
+    assertThat(i.getSchema().hasField(ProjectField.REF_STATE)).isTrue();
+
+    Optional<FieldBundle> result =
+        i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
+
+    assertThat(result.isPresent()).isTrue();
+    Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
+    assertThat(refState).isNotEmpty();
+
+    Map<Project.NameKey, Collection<RefState>> states = RefState.parseStates(refState).asMap();
+
+    fetch(testRepo, "refs/meta/config:refs/meta/config");
+    Ref projectConfigRef = testRepo.getRepository().exactRef("refs/meta/config");
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+    fetch(allProjectsRepo, "refs/meta/config:refs/meta/config");
+    Ref allProjectConfigRef = allProjectsRepo.getRepository().exactRef("refs/meta/config");
+    assertThat(states)
+        .containsExactly(
+            project,
+            ImmutableSet.of(RefState.of(projectConfigRef)),
+            allProjects,
+            ImmutableSet.of(RefState.of(allProjectConfigRef)));
+  }
+
+  @Test
+  public void stalenessChecker_currentProject_notStale() throws Exception {
+    assertThat(stalenessChecker.isStale(project)).isFalse();
+  }
+
+  @Test
+  public void stalenessChecker_currentProjectUpdates_isStale() throws Exception {
+    updateProjectConfigWithoutIndexUpdate(project);
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  @Test
+  public void stalenessChecker_parentProjectUpdates_isStale() throws Exception {
+    updateProjectConfigWithoutIndexUpdate(allProjects);
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  @Test
+  public void stalenessChecker_hierarchyChange_isStale() throws Exception {
+    Project.NameKey p1 = createProject("p1", allProjects);
+    Project.NameKey p2 = createProject("p2", allProjects);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setParentName(p1);
+      u.save();
+    }
+    assertThat(stalenessChecker.isStale(project)).isFalse();
+
+    updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
+    assertThat(stalenessChecker.isStale(project)).isTrue();
+  }
+
+  private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
+    updateProjectConfigWithoutIndexUpdate(
+        project, c -> c.getProject().setDescription("making it stale"));
+  }
+
+  private void updateProjectConfigWithoutIndexUpdate(
+      Project.NameKey project, Consumer<ProjectConfig> update) throws Exception {
+    try (AutoCloseable ignored = disableProjectIndex()) {
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        update.accept(u.getConfig());
+        u.save();
+      }
+    } catch (UnsupportedOperationException e) {
+      // Drop, as we just wanted to drop the index update
+      return;
+    }
+    fail("should have a UnsupportedOperationException");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index ec4f327..3295f1a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -17,13 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import org.junit.Test;
 
 @NoHttpd
@@ -38,6 +41,40 @@
   }
 
   @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentNotAllowedForNonOwners() throws Exception {
+    String parent = createProject("parent", null, true).get();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).parent(parent);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentAllowedByAdminWhenAllowProjectOwnersEnabled() throws Exception {
+    String parent = createProject("parent", null, true).get();
+
+    gApi.projects().name(project.get()).parent(parent);
+    assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+
+    // When the parent name is not explicitly set, it should be
+    // set to "All-Projects".
+    gApi.projects().name(project.get()).parent(null);
+    assertThat(gApi.projects().name(project.get()).parent())
+        .isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentAllowedForOwners() throws Exception {
+    String parent = createProject("parent", null, true).get();
+    setApiUser(user);
+    grant(project, "refs/*", Permission.OWNER, false, SystemGroupBackend.REGISTERED_USERS);
+    gApi.projects().name(project.get()).parent(parent);
+    assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+  }
+
+  @Test
   public void setParent() throws Exception {
     String parent = createProject("parent", null, true).get();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 4c8f53f..057f837 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -32,6 +32,10 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -112,16 +116,115 @@
   }
 
   @Test
-  public void diffDeletedFile() throws Exception {
+  public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
 
     Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
     assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
 
-    DiffInfo diff = getDiffRequest(changeId, CURRENT, FILE_NAME).get();
-    assertThat(diff.metaA.lines).isEqualTo(100);
-    assertThat(diff.metaB).isNull();
+  @Test
+  public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
+    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void numberOfLinesInDiffOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void deletedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfA()
+        .containsExactly("Line 1", "Line 2", "Line 3");
+  }
+
+  @Test
+  public void deletedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().deleteFile(filePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfA()
+        .containsExactly("Line 1", "Line 2", "Line 3", "");
   }
 
   @Test
@@ -136,6 +239,91 @@
   }
 
   @Test
+  public void numberOfLinesInDiffOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).metaA().isNull();
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void numberOfLinesInDiffOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).metaA().isNull();
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void numberOfLinesInFileInfoOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfB()
+        .containsExactly("Line 1", "Line 2", "Line 3");
+  }
+
+  @Test
+  public void addedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .linesOfB()
+        .containsExactly("Line 1", "Line 2", "Line 3", "");
+  }
+
+  @Test
   public void renamedFileIsIncludedInDiff() throws Exception {
     String newFilePath = "a_new_file.txt";
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
@@ -193,11 +381,11 @@
 
     // automerge
     diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaA.lines).isEqualTo(6);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaA.lines).isEqualTo(6);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     // parent 1
@@ -212,6 +400,596 @@
   }
 
   @Test
+  public void diffOfUnmodifiedFileMarksAllLinesAsCommon() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .onlyElement()
+        .commonLines()
+        .containsAllOf("Line 1", "Line 2", "Line 3")
+        .inOrder();
+    assertThat(diffInfo).content().onlyElement().linesOfA().isNull();
+    assertThat(diffInfo).content().onlyElement().linesOfB().isNull();
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    gApi.changes().id(changeId).edit().modifyCommitMessage("An unchanged patchset");
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("Line 3");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void diffOfModifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfModifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("Line 3");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void diffOfModifiedLastLineWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
+  }
+
+  @Test
+  public void diffOfModifiedLastLineWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3", "Line three"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().lastElement().linesOfA().containsExactly("Line 3");
+    assertThat(diffInfo).content().lastElement().linesOfB().containsExactly("Line three");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNull();
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "Line 102");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .withBase(previousPatchSetId)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line 101", "Line 102", "");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(103);
+  }
+
+  @Test
+  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNull();
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeAndAfterwardsMeansOneInsertedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(101);
+  }
+
+  @Test
+  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansOneInsertedLine()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void hunkForModifiedLastLineIsCombinedWithHunkForAddedNewlineAtEnd() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one oh one", "");
+  }
+
+  @Test
+  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 11));
+  }
+
+  @Test
+  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForAddedNewlineAtEnd()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n").concat("\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfA().isNull();
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("");
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 100");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(1).linesOfB().isNull();
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansOneDeletedLine()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    // Inherited from Git: An empty last line is ignored in the count.
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(1).linesOfB().isNull();
+    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeAndAfterwardsMeansOneDeletedLine() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(initialPatchSetId)
+            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
+            .get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");
+
+    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
+    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
+  }
+
+  @Test
+  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansTwoModifiedLines()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void hunkForModifiedLastLineIsCombinedWithHunkForDeletedNewlineAtEnd() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
+  }
+
+  @Test
+  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoDeletedAtEnd()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 4));
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 11));
+  }
+
+  @Test
+  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForDeletedNewlineAtEnd()
+      throws Exception {
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent ->
+            fileContent
+                .replace("Line 99\n", "Line ninety-nine\n")
+                .replace("Line 100\n", "Line 100"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line ninety-nine");
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("");
+    assertThat(diffInfo).content().element(3).linesOfB().isNull();
+  }
+
+  @Test
   public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
     ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
 
@@ -366,6 +1144,102 @@
   }
 
   @Test
+  public void singleHunkAtBeginningIsFollowedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(0).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .commonLines()
+        .containsExactly("Line 2", "Line 3", "Line 4", "Line 5", "")
+        .inOrder();
+  }
+
+  @Test
+  public void singleHunkAtEndIsPrecededByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2", "Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+  }
+
+  @Test
+  public void singleHunkInTheMiddleIsSurroundedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 4", "Line 5", "Line 6", "")
+        .inOrder();
+  }
+
+  @Test
+  public void twoHunksAreSeparatedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId,
+        filePath,
+        content -> content.replace("Line 2\n", "Line two\n").replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(3).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfB().isNotEmpty();
+  }
+
+  @Test
   public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
     String newFileContent =
         FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
@@ -381,15 +1255,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(4).isNotDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -412,15 +1286,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(5).isDueToRebase();
@@ -450,15 +1324,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(6).isNotDueToRebase();
@@ -489,11 +1363,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -522,7 +1396,7 @@
     assertThat(diffInfo).content().element(0).linesOfA().isNull();
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo)
         .content()
@@ -530,11 +1404,11 @@
         .linesOfB()
         .containsExactly("Line ten", "Line ten and a half");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -562,11 +1436,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -595,15 +1469,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().isNull();
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -623,7 +1497,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo)
         .content()
@@ -631,7 +1505,7 @@
         .linesOfB()
         .containsExactly("Line modified after rebase");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -655,7 +1529,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
     assertThat(diffInfo)
         .content()
@@ -663,7 +1537,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "Line forty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -687,7 +1561,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -695,7 +1569,7 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -717,7 +1591,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo)
         .content()
         .element(1)
@@ -729,7 +1603,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -755,7 +1629,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -763,11 +1637,11 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -791,15 +1665,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -834,15 +1708,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(7);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(90);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -879,19 +1753,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
     assertThat(diffInfo)
         .content()
@@ -899,15 +1773,15 @@
         .linesOfB()
         .containsExactly("Line eighteen", "Line nineteen");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(9).isDueToRebase();
-    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(10).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(11).isNotDueToRebase();
-    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(12).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -931,7 +1805,7 @@
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
     assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
-    assertThat(diffInfo).content().element(0).linesOfA().hasSize(100);
+    assertThat(diffInfo).content().element(0).linesOfA().hasSize(101);
     assertThat(diffInfo).content().element(0).linesOfB().isNull();
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
 
@@ -954,7 +1828,7 @@
         getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
     assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
     assertThat(diffInfo).content().element(0).linesOfA().isNull();
-    assertThat(diffInfo).content().element(0).linesOfB().hasSize(3);
+    assertThat(diffInfo).content().element(0).linesOfB().hasSize(4);
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
 
     Map<String, FileInfo> changedFiles =
@@ -980,11 +1854,11 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -1015,15 +1889,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1059,11 +1933,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1096,11 +1970,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1125,19 +1999,19 @@
 
     DiffInfo renamedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
-    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(renamedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(renamedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
 
     DiffInfo copiedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
-    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(copiedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(copiedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   /*
@@ -1168,23 +2042,23 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1239,19 +2113,19 @@
     String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     DiffInfo diffInfo =
         getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().isNull();
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
@@ -1282,7 +2156,7 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(99);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1312,11 +2186,11 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1335,7 +2209,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1343,7 +2217,7 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(94);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1372,19 +2246,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(13);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(80);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1411,19 +2285,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line eight");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1452,7 +2326,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1460,11 +2334,11 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line eight!");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1472,6 +2346,126 @@
     assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
   }
 
+  @Test
+  public void diffOfUnmodifiedFileWithWholeFileContextReturnsFileContents() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithCommentAndWholeFileContextReturnsFileContents()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
+            .withBase(initialPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    assertThat(diffInfo).content().isEmpty();
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // This behavior has been present in Gerrit for quite some time. It differs from the results
+    // returned for other cases (e.g. requesting the diff with whole file context for an unmodified
+    // file; requesting the diff with whole file context for a non-existent file). However, it's not
+    // completely clear what should be returned. The closest would be the result of a file deletion
+    // but that might also be misleading for users as actually a file rename occurred. In fact,
+    // requesting the diff result for the old file name of a renamed file is not a reasonable use
+    // case at all. We at least guarantee that we don't run into an internal error.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileWithCommentOnOldFileYieldsReasonableResult()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // See comment for requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult().
+    // This test should additionally ensure that we also don't run into an internal error when
+    // a comment is present.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  private static CommentInput createCommentInput(
+      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+    CommentInput comment = new CommentInput();
+    comment.range = new Comment.Range();
+    comment.range.startLine = startLine;
+    comment.range.startCharacter = startCharacter;
+    comment.range.endLine = endLine;
+    comment.range.endCharacter = endCharacter;
+    comment.message = message;
+    return comment;
+  }
+
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 1ae3283..ca4304e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -920,6 +920,7 @@
     CountDownLatch reindexed = new CountDownLatch(1);
     RegistrationHandle handle =
         changeIndexedListeners.add(
+            "gerrit",
             new ChangeIndexedListener() {
               @Override
               public void onChangeIndexed(String projectName, int id) {
@@ -1086,6 +1087,7 @@
     WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
     RegistrationHandle handle =
         patchSetLinks.add(
+            "gerrit",
             new PatchSetWebLink() {
               @Override
               public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
@@ -1226,6 +1228,26 @@
   }
 
   @Test
+  public void commentOnNonExistingFile() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r = updateChange(r, "new content");
+    CommentInput in = new CommentInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = "non-existing.txt";
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<CommentInput>> comments = new HashMap<>();
+    comments.put("non-existing.txt", Collections.singletonList(in));
+    reviewInput.comments = comments;
+    reviewInput.message = "comment test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format("not found in revision %d,1", r.getChange().change().getId().id));
+    gApi.changes().id(r.getChangeId()).revision(1).review(reviewInput);
+  }
+
+  @Test
   public void patch() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeApi changeApi = gApi.changes().id(r.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 8cc5c00..f1e67c1 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -71,6 +71,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.testing.EditInfoSubject;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Change;
@@ -84,7 +85,6 @@
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidators.ChangeIdValidator;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
@@ -103,6 +103,7 @@
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -212,6 +213,24 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void validateConnected() throws Exception {
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    testRepo.reset(c);
+
+    String r = "refs/heads/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    RevCommit amended =
+        testRepo.amend(c).message("different initial commit").insertChangeId().create();
+    testRepo.reset(amended);
+    r = "refs/for/master";
+    pr = pushHead(testRepo, r, false);
+    assertPushRejected(pr, r, "no common ancestry");
+  }
+
+  @Test
   public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
     // delete refs/meta/config
     try (Repository repo = repoManager.openRepository(project);
@@ -301,13 +320,14 @@
     r2.assertOkStatus();
     r2.assertChange(Change.Status.NEW, null);
     r2.assertMessage(
-        "New changes:\n"
+        "success\n"
+            + "\n"
+            + "New changes:\n"
             + "  "
             + url
             + id2
             + " another commit\n"
             + "\n"
-            + "\n"
             + "Updated changes:\n"
             + "  "
             + url
@@ -339,6 +359,20 @@
   }
 
   @Test
+  public void pushWithoutChangeIdDeprecated() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message("A change")
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()))
+        .create();
+    PushResult result = pushHead(testRepo, "refs/for/master");
+    assertThat(result.getMessages()).contains("warning: pushing without Change-Id is deprecated");
+  }
+
+  @Test
   public void autocloseByChangeId() throws Exception {
     // Create a change
     PushOneCommit.Result r = pushTo("refs/for/master");
@@ -656,11 +690,11 @@
     assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
-    // Other user trying to move from WIP to ready should fail.
+    // Admin user trying to move from WIP to ready should succeed.
     GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
     testRepo.reset("ps");
-    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
-    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user, testRepo);
+    r.assertOkStatus();
 
     // Other user trying to move from WIP to WIP should succeed.
     r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
@@ -672,14 +706,29 @@
     r.assertOkStatus();
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
-    // Other user trying to move from ready to WIP should fail.
+    // Admin user trying to move from ready to WIP should succeed.
     GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
-    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
+    r.assertOkStatus();
 
-    // Other user trying to move from ready to ready should succeed.
-    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
+    // Other user trying to move from wip to wip should succeed.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertOkStatus();
+
+    // Non owner, non admin and non project owner cannot flip wip bit:
+    TestAccount user2 = accountCreator.user2();
+    grant(
+        project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
+    TestRepository<?> user2Repo = cloneProject(project, user2);
+    GitUtil.fetch(user2Repo, r.getPatchSet().getRefName() + ":ps");
+    user2Repo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
+    r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
+
+    // Project owner trying to move from WIP to ready should succeed.
+    allow("refs/*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertOkStatus();
   }
 
@@ -1140,7 +1189,7 @@
         pushFactory.create(
             db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
-    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
+    r.assertErrorStatus("not Signed-off-by author/committer/uploader in message footer");
   }
 
   @Test
@@ -1213,7 +1262,7 @@
 
     // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
     // care that there is a new change.
-    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
+    assertThat(pr.getMessages()).containsMatch("changes: .*new: 1.*done");
     assertTwoChangesWithSameRevision(r);
   }
 
@@ -1344,7 +1393,7 @@
   private void testPushWithoutChangeId() throws Exception {
     RevCommit c = createCommit(testRepo, "Message without Change-Id");
     assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
-    pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
     pushForReviewOk(testRepo);
@@ -1369,8 +1418,9 @@
     r = push.to("refs/changes/" + r.getChange().change().getId().get());
     r.assertErrorStatus(
         String.format(
-            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG,
-            r.getCommit().abbreviate(RevId.ABBREV_LEN).name()));
+            "commit %s: %s",
+            r.getCommit().abbreviate(RevId.ABBREV_LEN).name(),
+            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG));
   }
 
   @Test
@@ -1391,10 +1441,10 @@
             + "\n"
             + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
             + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
   }
 
   @Test
@@ -1410,10 +1460,10 @@
 
   private void testpushWithInvalidChangeId() throws Exception {
     createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -1434,19 +1484,19 @@
         "Message with invalid Change-Id\n"
             + "\n"
             + "Change-Id: I0000000000000000000000000000000000000000\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
   }
 
   @Test
   public void pushWithChangeIdInSubjectLine() throws Exception {
     createCommit(testRepo, "Change-Id: I1234000000000000000000000000000000000000");
-    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
   }
 
   @Test
@@ -2022,7 +2072,8 @@
     assertPushOk(pushHead(testRepo, master), master);
 
     commits.addAll(initChanges(3));
-    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+    assertPushRejected(
+        pushHead(testRepo, master), master, "more than 2 commits, and skip-validation not set");
 
     grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
     PushResult r =
@@ -2064,7 +2115,7 @@
 
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
-    assertPushRejected(pr, ref, "prohibited by Gerrit: create not permitted for " + ref);
+    assertPushRejected(pr, ref, "prohibited by Gerrit: not permitted: create");
 
     grant(project, "refs/changes/*", Permission.CREATE);
     grant(project, "refs/changes/*", Permission.PUSH);
@@ -2086,11 +2137,11 @@
                 .push()
                 .setRefSpecs(
                     new RefSpec(noteDbCommit.name() + ":" + ref),
-                    new RefSpec(changeCommit.name() + ":refs/for/master"))
+                    new RefSpec(changeCommit.name() + ":refs/heads/permitted"))
                 .call());
 
     assertPushRejected(pr, ref, "NoteDb update requires -o notedb=allow");
-    assertPushOk(pr, "refs/for/master");
+    assertPushOk(pr, "refs/heads/permitted");
   }
 
   private DraftInput newDraft(String path, int line, String message) {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 62138ca..943b052 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -29,10 +29,22 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.StreamSupport;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -436,4 +448,61 @@
     RevCommit c = rw.parseCommit(commitId);
     assertThat(c.getFullMessage()).isEqualTo(expectedMessage);
   }
+
+  protected PersonIdent getAuthor(TestRepository<?> repo, String branch) throws Exception {
+    ObjectId commitId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + branch)
+            .getObjectId();
+
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    return c.getAuthorIdent();
+  }
+
+  protected void directUpdateSubmodule(String project, String refName, String path, AnyObjectId id)
+      throws Exception {
+    path = name(path);
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)));
+        ObjectInserter ins = serverRepo.newObjectInserter();
+        RevWalk rw = new RevWalk(serverRepo)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertThat(ref).named(refName).isNotNull();
+      ObjectId oldCommitId = ref.getObjectId();
+
+      DirCache dc = DirCache.newInCore();
+      DirCacheBuilder b = dc.builder();
+      b.addTree(
+          new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+      b.finish();
+      DirCacheEditor e = dc.editor();
+      e.add(
+          new PathEdit(path) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.GITLINK);
+              ent.setObjectId(id);
+            }
+          });
+      e.finish();
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.addParentId(oldCommitId);
+      cb.setTreeId(dc.writeTree(ins));
+      PersonIdent ident = serverIdent.get();
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      cb.setMessage("Direct update submodule " + path);
+      ObjectId newCommitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate ru = serverRepo.updateRef(refName);
+      ru.setExpectedOldObjectId(oldCommitId);
+      ru.setNewObjectId(newCommitId);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 7a6a3c4..3541fec 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -16,6 +16,7 @@
     srcs = ["AbstractPushForReview.java"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/mail",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index 87ac022..d80faa8 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -51,7 +51,7 @@
         pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
     push2.setForce(true);
     PushOneCommit.Result r2 = push2.to("refs/heads/master");
-    r2.assertErrorStatus("need 'Force Push' privilege.");
+    r2.assertErrorStatus("not permitted: force update");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index b362a36..907ad7f 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -46,6 +46,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.eclipse.jgit.transport.TrackingRefUpdate;
 import org.junit.Before;
 import org.junit.Test;
@@ -78,20 +79,40 @@
   }
 
   @Test
+  public void mixingMagicAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
+  public void mixingDirectChangesAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/changes/01/101");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/changes/01/101")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
   public void fastForwardUpdateDenied() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -99,24 +120,25 @@
   public void nonFastForwardUpdateDenied() throws Exception {
     ObjectId commit = testRepo.commit().create();
     PushResult r = push("+" + commit.name() + ":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("need 'Force Push' privilege.");
-    assertThat(r).hasNoMessages();
-    // TODO(dborowitz): Why does this not mention refs?
-    assertThat(r).hasProcessed(ImmutableMap.of());
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: force update");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
   @Test
   public void deleteDenied() throws Exception {
     PushResult r = push(":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("cannot delete references");
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
+            "error: branch refs/heads/master:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -126,8 +148,8 @@
     PushResult r = push("HEAD:refs/heads/newbranch");
     assertThat(r)
         .onlyRef("refs/heads/newbranch")
-        .isRejected("prohibited by Gerrit: create not permitted for refs/heads/newbranch");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: create");
+    assertThat(r).containsMessages("You need 'Create' rights to create new references.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -141,22 +163,20 @@
 
     testRepo.branch("HEAD").commit().create();
     PushResult r = push(":refs/heads/foo", ":refs/heads/bar", "HEAD:refs/heads/master");
-    assertThat(r).ref("refs/heads/foo").isRejected("cannot delete references");
-    assertThat(r).ref("refs/heads/bar").isRejected("cannot delete references");
+    assertThat(r).ref("refs/heads/foo").isRejected("prohibited by Gerrit: not permitted: delete");
+    assertThat(r).ref("refs/heads/bar").isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .ref("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branches refs/heads/foo, refs/heads/bar:",
+            "error: branches refs/heads/foo, refs/heads/bar:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
   }
 
   @Test
@@ -188,16 +208,14 @@
         // ReceiveCommits theoretically has a different message when a WRITE_CONFIG check fails, but
         // it never gets there, since DefaultPermissionBackend special-cases refs/meta/config and
         // denies UPDATE if the user is not a project owner.
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/meta/config:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/meta/config:",
             "Configuration changes can only be pushed by project owners",
             "who also have 'Push' rights on refs/meta/config",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
 
     grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
@@ -216,15 +234,12 @@
     PushResult r = push("HEAD:refs/for/master");
     assertThat(r)
         .onlyRef("refs/for/master")
-        .isRejected("create change not permitted for refs/heads/master");
+        .isRejected("prohibited by Gerrit: not permitted: create change on refs/heads/master");
     assertThat(r)
-        .hasMessages(
-            "Branch refs/heads/master:",
-            "You need 'Push' rights to upload code review requests.",
-            "Verify that you are pushing to the right branch.",
-            "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+        .containsMessages(
+            "error: branch refs/for/master:",
+            "You need 'Create Change' rights to upload code review requests.",
+            "Verify that you are pushing to the right branch.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -239,8 +254,10 @@
     PushResult r = push("HEAD:refs/for/master%submit");
     assertThat(r)
         .onlyRef("refs/for/master%submit")
-        .isRejected("update by submit not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: update by submit on refs/heads/master");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -274,8 +291,11 @@
         push(c -> c.setPushOptions(ImmutableList.of("skip-validation")), "HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("skip validation not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: skip validation");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Forge Author', 'Forge Server', 'Forge Committer'",
+            "and 'Push Merge' rights to skip validation.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -336,8 +356,7 @@
             c ->
                 cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
                     .getPermission(c, true)
-                    .getRules()
-                    .clear());
+                    .clearRules());
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 1de9d29..45294fb 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -433,9 +433,7 @@
       if (notesMigration.commitChangeWrites()) {
         PersonIdent committer = serverIdent.get();
         PersonIdent author =
-            noteUtil
-                .getLegacyChangeNoteWrite()
-                .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
+            noteUtil.newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
         tr.branch(RefNames.changeMetaRef(c3.getId()))
             .commit()
             .author(author)
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 0705dca..700b18b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -37,11 +37,8 @@
 import org.eclipse.jgit.api.errors.InvalidRemoteException;
 import org.eclipse.jgit.api.errors.TransportException;
 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.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -62,38 +59,6 @@
   }
 
   @Test
-  public void submitOnPushWithTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.CREATE);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.Tag tag = new PushOneCommit.Tag("v1.0");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
-  public void submitOnPushWithAnnotatedTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.AnnotatedTag tag =
-        new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
     grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
 
@@ -158,7 +123,7 @@
   @Test
   public void submitOnPushNotAllowed_Error() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit");
   }
 
   @Test
@@ -170,7 +135,7 @@
         push(
             "refs/for/master%submit",
             PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit ");
   }
 
   @Test
@@ -385,31 +350,6 @@
     }
   }
 
-  private void assertTag(Project.NameKey project, String branch, PushOneCommit.Tag tag)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Ref tagRef = repo.findRef(tag.name);
-      assertThat(tagRef).isNotNull();
-      ObjectId taggedCommit = null;
-      if (tag instanceof PushOneCommit.AnnotatedTag) {
-        PushOneCommit.AnnotatedTag annotatedTag = (PushOneCommit.AnnotatedTag) tag;
-        try (RevWalk rw = new RevWalk(repo)) {
-          RevObject object = rw.parseAny(tagRef.getObjectId());
-          assertThat(object).isInstanceOf(RevTag.class);
-          RevTag tagObject = (RevTag) object;
-          assertThat(tagObject.getFullMessage()).isEqualTo(annotatedTag.message);
-          assertThat(tagObject.getTaggerIdent()).isEqualTo(annotatedTag.tagger);
-          taggedCommit = tagObject.getObject();
-        }
-      } else {
-        taggedCommit = tagRef.getObjectId();
-      }
-      ObjectId headCommit = repo.exactRef(branch).getObjectId();
-      assertThat(taggedCommit).isNotNull();
-      assertThat(taggedCommit).isEqualTo(headCommit);
-    }
-  }
-
   private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 81ee3a0..98e3cae 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,15 +16,20 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -520,6 +525,196 @@
     testSubmoduleSubjectCommitMessageAndExpectTruncation();
   }
 
+  @Test
+  public void superRepoCommitHasSameAuthorAsSubmoduleCommit() throws Exception {
+    // Make sure that the commit is created at an earlier timestamp than the submit timestamp.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      TestRepository<?> superRepo = createProjectWithPush("super-project");
+      TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+      allowMatchingSubmoduleSubscription(
+          "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+      createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+      PushOneCommit.Result pushResult =
+          createChange(subRepo, "refs/heads/master", "Change", "a.txt", "some content", null);
+      approve(pushResult.getChangeId());
+      gApi.changes().id(pushResult.getChangeId()).current().submit();
+
+      // Expect that the author name/email is preserved for the superRepo commit, but a new author
+      // timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(admin.fullName);
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email);
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void superRepoCommitHasSameAuthorAsSubmoduleCommits() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // Make sure that the commits are created at different timestamps and that the submit timestamp
+    // is afterwards.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      TestRepository<?> superRepo = createProjectWithPush("super-project");
+      TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+      TestRepository<?> subRepo2 = createProjectWithPush("subscribed-to-project-2");
+
+      allowMatchingSubmoduleSubscription(
+          "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+      allowMatchingSubmoduleSubscription(
+          "subscribed-to-project-2", "refs/heads/master", "super-project", "refs/heads/master");
+
+      Config config = new Config();
+      prepareSubmoduleConfigEntry(config, "subscribed-to-project", "master");
+      prepareSubmoduleConfigEntry(config, "subscribed-to-project-2", "master");
+      pushSubmoduleConfig(superRepo, "master", config);
+
+      String topic = "foo";
+
+      PushOneCommit.Result pushResult1 =
+          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+      approve(pushResult1.getChangeId());
+
+      PushOneCommit.Result pushResult2 =
+          createChange(subRepo2, "refs/heads/master", "Change 2", "b.txt", "other content", topic);
+      approve(pushResult2.getChangeId());
+
+      // Submit the topic, 2 changes with the same author.
+      gApi.changes().id(pushResult1.getChangeId()).current().submit();
+
+      // Expect that the author name/email is preserved for the superRepo commit, but a new author
+      // timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(admin.fullName);
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(admin.email);
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void superRepoCommitHasGerritAsAuthorIfAuthorsOfSubmoduleCommitsDiffer() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // Make sure that the commits are created at different timestamps and that the submit timestamp
+    // is afterwards.
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      TestRepository<?> superRepo = createProjectWithPush("super-project");
+      TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+      TestRepository<?> subRepo2 = createProjectWithPush("subscribed-to-project-2");
+      subRepo2 = cloneProject(new Project.NameKey(name("subscribed-to-project-2")), user);
+
+      allowMatchingSubmoduleSubscription(
+          "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+      allowMatchingSubmoduleSubscription(
+          "subscribed-to-project-2", "refs/heads/master", "super-project", "refs/heads/master");
+
+      Config config = new Config();
+      prepareSubmoduleConfigEntry(config, "subscribed-to-project", "master");
+      prepareSubmoduleConfigEntry(config, "subscribed-to-project-2", "master");
+      pushSubmoduleConfig(superRepo, "master", config);
+
+      String topic = "foo";
+
+      // Create change as admin.
+      PushOneCommit.Result pushResult1 =
+          createChange(subRepo, "refs/heads/master", "Change 1", "a.txt", "some content", topic);
+      approve(pushResult1.getChangeId());
+
+      // Create change as user.
+      PushOneCommit push =
+          pushFactory.create(db, user.getIdent(), subRepo2, "Change 2", "b.txt", "other content");
+      PushOneCommit.Result pushResult2 = push.to("refs/for/master/" + name(topic));
+      approve(pushResult2.getChangeId());
+
+      // Submit the topic, 2 changes with the different author.
+      gApi.changes().id(pushResult1.getChangeId()).current().submit();
+
+      // Expect that the Gerrit server identity is chosen as author for the superRepo commit and a
+      // new author timestamp is used.
+      PersonIdent authorIdent = getAuthor(superRepo, "master");
+      assertThat(authorIdent.getName()).isEqualTo(serverIdent.get().getName());
+      assertThat(authorIdent.getEmailAddress()).isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult1.getCommit().getAuthorIdent().getWhen());
+      assertThat(authorIdent.getWhen())
+          .isGreaterThan(pushResult2.getCommit().getAuthorIdent().getWhen());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void updateOnlyRelevantSubmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo1 = createProjectWithPush("subscribed-to-project-1");
+    TestRepository<?> subRepo2 = createProjectWithPush("subscribed-to-project-2");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-1", "master");
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Push once to initialize submodules.
+    ObjectId subTip2 = pushChangeTo(subRepo2, "master");
+    ObjectId subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+
+    directUpdateRef("subscribed-to-project-2", "refs/heads/master");
+    subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+  }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    // Push once to initialize submodule.
+    ObjectId subTip = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subTip);
+
+    // Write an invalid SHA-1 directly to the gitlink.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "subscribed-to-project", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+
+    // Push succeeds, but gitlink update is skipped.
+    pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+  }
+
+  private ObjectId directUpdateRef(String project, String ref) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)))) {
+      return new TestRepository<>(serverRepo).branch(ref).commit().create().copy();
+    }
+  }
+
   private void testSubmoduleSubjectCommitMessageAndExpectTruncation() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 2812c86..eef3295 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -856,4 +856,44 @@
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Write an invalid SHA-1 directly to one of the gitlinks.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+
+    String topic = "same-topic";
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
+
+    String changeId1 = getChangeId(sub1, sub1Id).get();
+    String changeId2 = getChangeId(sub2, sub2Id).get();
+    approve(changeId1);
+    approve(changeId2);
+
+    gApi.changes().id(changeId1).current().submit();
+
+    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
+
+    // sub1 was skipped but sub2 succeeded.
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index 0d24a5d..c166acfb 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import java.nio.file.Files;
 import java.util.Set;
@@ -46,6 +47,9 @@
 
 @NoHttpd
 public abstract class AbstractReindexTests extends StandaloneSiteTest {
+  /** @param injector injector */
+  public abstract void configureIndex(Injector injector) throws Exception;
+
   private static final String CHANGES = ChangeSchemaDefinitions.NAME;
 
   private Project.NameKey project;
@@ -225,6 +229,7 @@
   private void setUpChange() throws Exception {
     project = new Project.NameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
+      configureIndex(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.projects().create(project.get());
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index a8d5644..a91b815 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -21,14 +21,19 @@
     srcs = ["ElasticReindexIT.java"],
     group = "elastic",
     labels = [
+        "docker",
         "elastic",
+        "exclusive",
+        "flaky",
         "pgm",
         "no_windows",
     ],
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/server/schema",
+        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 1aa8d54..0d5d2cd 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -14,7 +14,51 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import org.junit.Ignore;
+import com.google.gerrit.elasticsearch.ElasticContainer;
+import com.google.gerrit.elasticsearch.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
 
-@Ignore
-public class ElasticReindexIT extends AbstractReindexTests {}
+public class ElasticReindexIT extends AbstractReindexTests {
+
+  private static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
+  @ConfigSuite.Default
+  public static Config elasticsearchV2() {
+    return getConfig(ElasticVersion.V2_4);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV5() {
+    return getConfig(ElasticVersion.V5_6);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
+  }
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Before
+  public void reindexFirstSinceElastic() throws Exception {
+    assertServerStartupFails();
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
index 18d2628..223851e 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
@@ -14,4 +14,9 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-public class ReindexIT extends AbstractReindexTests {}
+import com.google.inject.Injector;
+
+public class ReindexIT extends AbstractReindexTests {
+  @Override
+  public void configureIndex(Injector injector) {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java b/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java
new file mode 100644
index 0000000..2bb3dca
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.apache.http.HttpStatus.SC_FORBIDDEN;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import java.util.List;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.junit.Ignore;
+
+/**
+ * Base class for testing the REST API bindings.
+ *
+ * <p>This test sends a request to each REST endpoint and verifies that an implementation is found
+ * (no '404 Not Found' response) and that the request doesn't fail (no '500 Internal Server Error'
+ * response). It doesn't verify that the REST endpoint works correctly. This is okay since the
+ * purpose of the test is only to verify that the REST endpoint implementations are correctly bound.
+ */
+@Ignore
+public abstract class AbstractRestApiBindingsTest extends AbstractDaemonTest {
+  protected void execute(List<RestCall> restCalls, String... args) throws Exception {
+    execute(restCalls, () -> {}, args);
+  }
+
+  protected void execute(List<RestCall> restCalls, BeforeRestCall beforeRestCall, String... args)
+      throws Exception {
+    for (RestCall restCall : restCalls) {
+      beforeRestCall.run();
+      execute(restCall, args);
+    }
+  }
+
+  protected void execute(RestCall restCall, String... args) throws Exception {
+    String method = restCall.httpMethod().name();
+    String uri = restCall.uri(args);
+
+    RestResponse response;
+    switch (restCall.httpMethod()) {
+      case GET:
+        response = adminRestSession.get(uri);
+        break;
+      case PUT:
+        response = adminRestSession.put(uri);
+        break;
+      case POST:
+        response = adminRestSession.post(uri);
+        break;
+      case DELETE:
+        response = adminRestSession.delete(uri);
+        break;
+      default:
+        fail("unsupported method: %s", restCall.httpMethod().name());
+        throw new IllegalStateException();
+    }
+
+    int status = response.getStatusCode();
+    String body = response.hasContent() ? response.getEntityContent() : "";
+
+    String msg = String.format("%s %s returned %d: %s", method, uri, status, body);
+    if (restCall.expectedResponseCode().isPresent()) {
+      assertWithMessage(msg).that(status).isEqualTo(restCall.expectedResponseCode().get());
+      if (restCall.expectedMessage().isPresent()) {
+        assertWithMessage(msg).that(body).contains(restCall.expectedMessage().get());
+      }
+    } else {
+      assertWithMessage(msg)
+          .that(status)
+          .isNotIn(ImmutableList.of(SC_FORBIDDEN, SC_NOT_FOUND, SC_METHOD_NOT_ALLOWED));
+      assertWithMessage(msg).that(status).isLessThan(SC_INTERNAL_SERVER_ERROR);
+    }
+  }
+
+  enum Method {
+    GET,
+    PUT,
+    POST,
+    DELETE
+  }
+
+  @AutoValue
+  abstract static class RestCall {
+    static RestCall get(String uriFormat) {
+      return builder(Method.GET, uriFormat).build();
+    }
+
+    static RestCall put(String uriFormat) {
+      return builder(Method.PUT, uriFormat).build();
+    }
+
+    static RestCall post(String uriFormat) {
+      return builder(Method.POST, uriFormat).build();
+    }
+
+    static RestCall delete(String uriFormat) {
+      return builder(Method.DELETE, uriFormat).build();
+    }
+
+    static Builder builder(Method httpMethod, String uriFormat) {
+      return new AutoValue_AbstractRestApiBindingsTest_RestCall.Builder()
+          .httpMethod(httpMethod)
+          .uriFormat(uriFormat);
+    }
+
+    abstract Method httpMethod();
+
+    abstract String uriFormat();
+
+    abstract Optional<Integer> expectedResponseCode();
+
+    abstract Optional<String> expectedMessage();
+
+    String uri(String... args) {
+      String uriFormat = uriFormat();
+      int expectedArgNum = StringUtils.countMatches(uriFormat, "%s");
+      checkState(
+          args.length == expectedArgNum,
+          "uriFormat %s needs %s arguments, got only %s: %s",
+          uriFormat,
+          expectedArgNum,
+          args.length,
+          args);
+      return String.format(uriFormat, (Object[]) args);
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder httpMethod(Method httpMethod);
+
+      abstract Builder uriFormat(String uriFormat);
+
+      abstract Builder expectedResponseCode(int expectedResponseCode);
+
+      abstract Builder expectedMessage(String expectedMessage);
+
+      abstract RestCall build();
+    }
+  }
+
+  @FunctionalInterface
+  public interface BeforeRestCall {
+    void run() throws Exception;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java
new file mode 100644
index 0000000..b0adba7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.PUT;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the accounts REST API.
+ *
+ * <p>These tests only verify that the account REST endpoints are correctly bound, they do no test
+ * the functionality of the account REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class AccountsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+
+  /**
+   * Account REST endpoints to be tested, each URL contains a placeholder for the account
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> ACCOUNT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s"),
+          RestCall.put("/accounts/%s"),
+          RestCall.get("/accounts/%s/detail"),
+          RestCall.get("/accounts/%s/name"),
+          RestCall.put("/accounts/%s/name"),
+          RestCall.delete("/accounts/%s/name"),
+          RestCall.get("/accounts/%s/username"),
+          RestCall.builder(PUT, "/accounts/%s/username")
+              // Changing the username is not allowed.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("Username cannot be changed.")
+              .build(),
+          RestCall.get("/accounts/%s/active"),
+          RestCall.put("/accounts/%s/active"),
+          RestCall.delete("/accounts/%s/active"),
+          RestCall.put("/accounts/%s/password.http"),
+          RestCall.delete("/accounts/%s/password.http"),
+          RestCall.get("/accounts/%s/status"),
+          RestCall.put("/accounts/%s/status"),
+          RestCall.get("/accounts/%s/avatar"),
+          RestCall.get("/accounts/%s/avatar.change.url"),
+          RestCall.get("/accounts/%s/emails/"),
+          RestCall.put("/accounts/%s/emails/new-email@foo.com"),
+          RestCall.get("/accounts/%s/sshkeys/"),
+          RestCall.post("/accounts/%s/sshkeys/"),
+          RestCall.get("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects:delete"),
+          RestCall.get("/accounts/%s/groups"),
+          RestCall.get("/accounts/%s/preferences"),
+          RestCall.put("/accounts/%s/preferences"),
+          RestCall.get("/accounts/%s/preferences.diff"),
+          RestCall.put("/accounts/%s/preferences.diff"),
+          RestCall.get("/accounts/%s/preferences.edit"),
+          RestCall.put("/accounts/%s/preferences.edit"),
+          RestCall.get("/accounts/%s/starred.changes"),
+          RestCall.get("/accounts/%s/stars.changes"),
+          RestCall.post("/accounts/%s/index"),
+          RestCall.get("/accounts/%s/agreements"),
+          RestCall.put("/accounts/%s/agreements"),
+          RestCall.get("/accounts/%s/external.ids"),
+          RestCall.post("/accounts/%s/external.ids:delete"),
+          RestCall.post("/accounts/%s/drafts:delete"),
+          RestCall.get("/accounts/%s/oauthtoken"),
+          RestCall.get("/accounts/%s/capabilities"),
+          RestCall.get("/accounts/%s/capabilities/viewPlugins"),
+          RestCall.get("/accounts/%s/gpgkeys"),
+          RestCall.post("/accounts/%s/gpgkeys"));
+
+  /**
+   * Email REST endpoints to be tested, each URL contains a placeholders for the account and email
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> EMAIL_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/emails/%s"),
+          RestCall.put("/accounts/%s/emails/%s"),
+          RestCall.put("/accounts/%s/emails/%s/preferred"),
+
+          // email deletion must be tested last
+          RestCall.delete("/accounts/%s/emails/%s"));
+
+  /**
+   * GPG key REST endpoints to be tested, each URL contains a placeholders for the account
+   * identifier and the GPG key identifier.
+   */
+  private static final ImmutableList<RestCall> GPG_KEY_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/gpgkeys/%s"),
+
+          // GPG key deletion must be tested last
+          RestCall.delete("/accounts/%s/gpgkeys/%s"));
+
+  /**
+   * SSH key REST endpoints to be tested, each URL contains a placeholders for the account and SSH
+   * key identifier.
+   */
+  private static final ImmutableList<RestCall> SSH_KEY_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/sshkeys/%s"),
+
+          // SSH key deletion must be tested last
+          RestCall.delete("/accounts/%s/sshkeys/%s"));
+
+  /**
+   * Star REST endpoints to be tested, each URL contains a placeholders for the account and change
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> STAR_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/accounts/%s/starred.changes/%s"),
+          RestCall.delete("/accounts/%s/starred.changes/%s"),
+          RestCall.get("/accounts/%s/stars.changes/%s"),
+          RestCall.post("/accounts/%s/stars.changes/%s"));
+
+  @Test
+  public void accountEndpoints() throws Exception {
+    execute(ACCOUNT_ENDPOINTS, "self");
+  }
+
+  @Test
+  public void emailEndpoints() throws Exception {
+    execute(EMAIL_ENDPOINTS, "self", admin.email);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  public void gpgKeyEndpoints() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    String id = key.getKeyIdString();
+
+    String email = "test1@example.com"; // email that is hard-coded in the test GPG key
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Add Email",
+            admin.getId(),
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(name("test"), email, admin.getId(), email)));
+
+    setApiUser(admin);
+    gApi.accounts()
+        .self()
+        .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()), ImmutableList.of());
+
+    execute(GPG_KEY_ENDPOINTS, "self", id);
+  }
+
+  @Test
+  @UseSsh
+  public void sshKeyEndpoints() throws Exception {
+    String sshKeySeq = Integer.toString(gApi.accounts().self().listSshKeys().size());
+    execute(SSH_KEY_ENDPOINTS, "self", sshKeySeq);
+  }
+
+  @Test
+  public void starEndpoints() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Test change");
+    String changeId = gApi.changes().create(ci).get().id;
+    execute(STAR_ENDPOINTS, "self", changeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
new file mode 100644
index 0000000..b94a98d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -0,0 +1,24 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_bindings",
+    labels = ["rest"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/logging",
+    ],
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = [
+        "AbstractRestApiBindingsTest.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//lib/commons:lang",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
new file mode 100644
index 0000000..59c0903
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
@@ -0,0 +1,510 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.GET;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.reviewdb.client.Patch;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the changes REST API.
+ *
+ * <p>These tests only verify that the change REST endpoints are correctly bound, they do no test
+ * the functionality of the change REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class ChangesRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  /**
+   * Change REST endpoints to be tested, each URL contains a placeholder for the change identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s"),
+          RestCall.get("/changes/%s/detail"),
+          RestCall.get("/changes/%s/topic"),
+          RestCall.put("/changes/%s/topic"),
+          RestCall.delete("/changes/%s/topic"),
+          RestCall.get("/changes/%s/in"),
+          RestCall.get("/changes/%s/hashtags"),
+          RestCall.get("/changes/%s/comments"),
+          RestCall.get("/changes/%s/robotcomments"),
+          RestCall.get("/changes/%s/drafts"),
+          RestCall.get("/changes/%s/assignee"),
+          RestCall.get("/changes/%s/past_assignees"),
+          RestCall.put("/changes/%s/assignee"),
+          RestCall.delete("/changes/%s/assignee"),
+          RestCall.post("/changes/%s/private"),
+          RestCall.post("/changes/%s/private.delete"),
+          RestCall.delete("/changes/%s/private"),
+          RestCall.post("/changes/%s/wip"),
+          RestCall.post("/changes/%s/ready"),
+          RestCall.put("/changes/%s/ignore"),
+          RestCall.put("/changes/%s/unignore"),
+          RestCall.put("/changes/%s/reviewed"),
+          RestCall.put("/changes/%s/unreviewed"),
+          RestCall.get("/changes/%s/messages"),
+          RestCall.put("/changes/%s/message"),
+          RestCall.post("/changes/%s/merge"),
+          RestCall.post("/changes/%s/abandon"),
+          RestCall.post("/changes/%s/move"),
+          RestCall.post("/changes/%s/rebase"),
+          RestCall.post("/changes/%s/restore"),
+          RestCall.post("/changes/%s/revert"),
+          RestCall.get("/changes/%s/pure_revert"),
+          RestCall.post("/changes/%s/submit"),
+          RestCall.get("/changes/%s/submitted_together"),
+          RestCall.post("/changes/%s/index"),
+          RestCall.get("/changes/%s/check"),
+          RestCall.post("/changes/%s/check"),
+          RestCall.get("/changes/%s/reviewers"),
+          RestCall.post("/changes/%s/reviewers"),
+          RestCall.get("/changes/%s/suggest_reviewers"),
+          RestCall.builder(GET, "/changes/%s/revisions")
+              // GET /changes/<change-id>/revisions is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit:rebase"),
+          RestCall.get("/changes/%s/edit:message"),
+          RestCall.put("/changes/%s/edit:message"),
+
+          // Publish edit and create a new edit
+          RestCall.post("/changes/%s/edit:publish"),
+          RestCall.put("/changes/%s/edit/a.txt"),
+
+          // Deletion of change edit and change must be tested last
+          RestCall.delete("/changes/%s/edit"),
+          RestCall.delete("/changes/%s"));
+
+  /**
+   * Change REST endpoints to be tested with NoteDb, each URL contains a placeholder for the change
+   * identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_ENDPOINTS_NOTEDB =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/hashtags"), RestCall.post("/changes/%s/rebuild.notedb"));
+
+  /**
+   * Reviewer REST endpoints to be tested, each URL contains placeholders for the change identifier
+   * and the reviewer identifier.
+   */
+  private static final ImmutableList<RestCall> REVIEWER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/reviewers/%s"),
+          RestCall.get("/changes/%s/reviewers/%s/votes"),
+          RestCall.post("/changes/%s/reviewers/%s/delete"),
+          RestCall.delete("/changes/%s/reviewers/%s"));
+
+  /**
+   * Vote REST endpoints to be tested, each URL contains placeholders for the change identifier, the
+   * reviewer identifier and the label identifier.
+   */
+  private static final ImmutableList<RestCall> VOTE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/reviewers/%s/votes/%s/delete"),
+          RestCall.delete("/changes/%s/reviewers/%s/votes/%s"));
+
+  /**
+   * Revision REST endpoints to be tested, each URL contains placeholders for the change identifier
+   * and the revision identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/actions"),
+          RestCall.post("/changes/%s/revisions/%s/cherrypick"),
+          RestCall.get("/changes/%s/revisions/%s/commit"),
+          RestCall.get("/changes/%s/revisions/%s/mergeable"),
+          RestCall.get("/changes/%s/revisions/%s/related"),
+          RestCall.get("/changes/%s/revisions/%s/review"),
+          RestCall.post("/changes/%s/revisions/%s/review"),
+          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
+          RestCall.post("/changes/%s/revisions/%s/submit"),
+          RestCall.get("/changes/%s/revisions/%s/submit_type"),
+          RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
+          RestCall.post("/changes/%s/revisions/%s/test.submit_type"),
+          RestCall.post("/changes/%s/revisions/%s/rebase"),
+          RestCall.get("/changes/%s/revisions/%s/description"),
+          RestCall.put("/changes/%s/revisions/%s/description"),
+          RestCall.get("/changes/%s/revisions/%s/patch"),
+          RestCall.get("/changes/%s/revisions/%s/archive"),
+          RestCall.get("/changes/%s/revisions/%s/mergelist"),
+          RestCall.get("/changes/%s/revisions/%s/reviewers"),
+          RestCall.get("/changes/%s/revisions/%s/drafts"),
+          RestCall.put("/changes/%s/revisions/%s/drafts"),
+          RestCall.get("/changes/%s/revisions/%s/comments"),
+          RestCall.get("/changes/%s/revisions/%s/robotcomments"),
+          RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
+              // GET /changes/<change>/revisions/<revision>/fixes is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/%s/revisions/%s/files"));
+
+  /**
+   * Revision reviewer REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the reviewer identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_REVIEWER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/reviewers/%s"),
+          RestCall.get("/changes/%s/revisions/%s/reviewers/%s/votes"),
+          RestCall.post("/changes/%s/revisions/%s/reviewers/%s/delete"),
+          RestCall.delete("/changes/%s/revisions/%s/reviewers/%s"));
+
+  /**
+   * Revision vote REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier, the reviewer identifier and the label identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_VOTE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/revisions/%s/reviewers/%s/votes/%s/delete"),
+          RestCall.delete("/changes/%s/revisions/%s/reviewers/%s/votes/%s"));
+
+  /**
+   * Draft comment REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the draft comment identifier.
+   */
+  private static final ImmutableList<RestCall> DRAFT_COMMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/drafts/%s"),
+          RestCall.put("/changes/%s/revisions/%s/drafts/%s"),
+          RestCall.delete("/changes/%s/revisions/%s/drafts/%s"));
+
+  /**
+   * Comment REST endpoints to be tested, each URL contains placeholders for the change identifier,
+   * the revision identifier and the comment identifier.
+   */
+  private static final ImmutableList<RestCall> COMMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/changes/%s/revisions/%s/comments/%s"),
+          RestCall.delete("/changes/%s/revisions/%s/comments/%s"),
+          RestCall.post("/changes/%s/revisions/%s/comments/%s/delete"));
+
+  /**
+   * Robot comment REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the robot comment identifier.
+   */
+  private static final ImmutableList<RestCall> ROBOT_COMMENT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/changes/%s/revisions/%s/robotcomments/%s"));
+
+  /**
+   * Fix REST endpoints to be tested, each URL contains placeholders for the change identifier, the
+   * revision identifier and the fix identifier.
+   */
+  private static final ImmutableList<RestCall> FIX_ENDPOINTS =
+      ImmutableList.of(RestCall.post("/changes/%s/revisions/%s/fixes/%s/apply"));
+
+  /**
+   * Revision file REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier, the revision identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> REVISION_FILE_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.delete("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/content"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/download"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/diff"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/blame"));
+
+  /**
+   * Change message REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change message identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_MESSAGE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/changes/%s/messages/%s"));
+
+  /**
+   * Change edit REST endpoints that create an edit to be tested, each URL contains placeholders for
+   * the change identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_CREATE_ENDPOINTS =
+      ImmutableList.of(
+          // Create change edit by editing an existing file.
+          RestCall.put("/changes/%s/edit/%s"),
+
+          // Create change edit by deleting an existing file.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  /**
+   * Change edit REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_ENDPOINTS =
+      ImmutableList.of(
+          // Calls on existing change edit.
+          RestCall.get("/changes/%s/edit/%s"),
+          RestCall.put("/changes/%s/edit/%s"),
+          RestCall.get("/changes/%s/edit/%s/meta"),
+
+          // Delete content of a file in an existing change edit.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  private static final String FILENAME = "test.txt";
+
+  @Test
+  public void changeEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    execute(CHANGE_ENDPOINTS, changeId);
+  }
+
+  @Test
+  public void changeEndpointsNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeId = createChange().getChangeId();
+    execute(CHANGE_ENDPOINTS_NOTEDB, changeId);
+  }
+
+  @Test
+  public void reviewerEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email;
+
+    execute(
+        REVIEWER_ENDPOINTS,
+        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        changeId,
+        addReviewerInput.reviewer);
+  }
+
+  @Test
+  public void voteEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    execute(
+        VOTE_ENDPOINTS,
+        () -> gApi.changes().id(changeId).current().review(ReviewInput.approve()),
+        changeId,
+        admin.email,
+        "Code-Review");
+  }
+
+  @Test
+  public void revisionEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    execute(REVISION_ENDPOINTS, changeId, "current");
+  }
+
+  @Test
+  public void revisionReviewerEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email;
+
+    execute(
+        REVISION_REVIEWER_ENDPOINTS,
+        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        changeId,
+        "current",
+        addReviewerInput.reviewer);
+  }
+
+  @Test
+  public void revisionVoteEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    execute(
+        REVISION_VOTE_ENDPOINTS,
+        () -> gApi.changes().id(changeId).current().review(ReviewInput.approve()),
+        changeId,
+        "current",
+        admin.email,
+        "Code-Review");
+  }
+
+  @Test
+  public void draftCommentEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    for (RestCall restCall : DRAFT_COMMENT_ENDPOINTS) {
+      DraftInput draftInput = new DraftInput();
+      draftInput.path = Patch.COMMIT_MSG;
+      draftInput.side = Side.REVISION;
+      draftInput.line = 1;
+      draftInput.message = "draft comment";
+      CommentInfo draftInfo = gApi.changes().id(changeId).current().createDraft(draftInput).get();
+
+      execute(restCall, changeId, "current", draftInfo.id);
+    }
+  }
+
+  @Test
+  public void commentEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    for (RestCall restCall : COMMENT_ENDPOINTS) {
+      DraftInput draftInput = new DraftInput();
+      draftInput.path = Patch.COMMIT_MSG;
+      draftInput.side = Side.REVISION;
+      draftInput.line = 1;
+      draftInput.message = "draft comment";
+      CommentInfo commentInfo = gApi.changes().id(changeId).current().createDraft(draftInput).get();
+
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.drafts = DraftHandling.PUBLISH;
+      gApi.changes().id(changeId).current().review(reviewInput);
+
+      execute(restCall, changeId, "current", commentInfo.id);
+    }
+  }
+
+  @Test
+  public void robotCommentEndpoints() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeId = createChange().getChangeId();
+
+    RobotCommentInput robotCommentInput = new RobotCommentInput();
+    robotCommentInput.robotId = "happyRobot";
+    robotCommentInput.robotRunId = "1";
+    robotCommentInput.line = 1;
+    robotCommentInput.message = "nit: trailing whitespace";
+    robotCommentInput.path = Patch.COMMIT_MSG;
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+
+    execute(ROBOT_COMMENT_ENDPOINTS, changeId, "current", robotCommentInfo.id);
+  }
+
+  @Test
+  public void fixEndpoints() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+
+    RobotCommentInput robotCommentInput = new RobotCommentInput();
+    robotCommentInput.robotId = "happyRobot";
+    robotCommentInput.robotRunId = "1";
+    robotCommentInput.line = 1;
+    robotCommentInput.message = "nit: trailing whitespace";
+    robotCommentInput.path = FILENAME;
+
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = FILENAME;
+    fixReplacementInfo.replacement = "some replacement code";
+    fixReplacementInfo.range = createRange(1, 1, 1, 2);
+
+    FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
+    fixSuggestionInfo.fixId = "An ID which must be overwritten.";
+    fixSuggestionInfo.description = "A description for a suggested fix.";
+    fixSuggestionInfo.replacements = ImmutableList.of(fixReplacementInfo);
+
+    robotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    execute(FIX_ENDPOINTS, changeId, "current", fixId);
+  }
+
+  @Test
+  public void revisionFileEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    execute(REVISION_FILE_ENDPOINTS, changeId, "current", FILENAME);
+  }
+
+  @Test
+  public void changeMessageEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // A change message is created on change creation.
+    String changeMessageId = Iterables.getOnlyElement(gApi.changes().id(changeId).messages()).id;
+
+    execute(CHANGE_MESSAGE_ENDPOINTS, changeId, changeMessageId);
+  }
+
+  @Test
+  public void changeEditCreateEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+
+    // Each of the REST calls creates the change edit newly.
+    execute(
+        CHANGE_EDIT_CREATE_ENDPOINTS,
+        () -> adminRestSession.delete("/changes/" + changeId + "/edit"),
+        changeId,
+        FILENAME);
+  }
+
+  @Test
+  public void changeEditEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    execute(CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments
+        .stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
new file mode 100644
index 0000000..508d407
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the config REST API.
+ *
+ * <p>These tests only verify that the config REST endpoints are correctly bound, they do no test
+ * the functionality of the config REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class ConfigRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  /**
+   * Config REST endpoints to be tested, the URLs contain no placeholders since the only supported
+   * config identifier ('server') can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> CONFIG_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/config/server/version"),
+          RestCall.get("/config/server/info"),
+          RestCall.get("/config/server/preferences"),
+          RestCall.put("/config/server/preferences"),
+          RestCall.get("/config/server/preferences.diff"),
+          RestCall.put("/config/server/preferences.diff"),
+          RestCall.get("/config/server/preferences.edit"),
+          RestCall.put("/config/server/preferences.edit"),
+          RestCall.get("/config/server/top-menus"),
+          RestCall.put("/config/server/email.confirm"),
+          RestCall.post("/config/server/check.consistency"),
+          RestCall.post("/config/server/reload"),
+          RestCall.get("/config/server/summary"),
+          RestCall.get("/config/server/capabilities"),
+          RestCall.get("/config/server/caches"),
+          RestCall.post("/config/server/caches"),
+          RestCall.get("/config/server/tasks"));
+
+  /**
+   * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
+   * Since there is only supported a single supported config identifier ('server') it can be
+   * hard-coded.
+   */
+  private static final ImmutableList<RestCall> CACHE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/config/server/caches/%s"));
+
+  /**
+   * Task REST endpoints to be tested, the URLs contain a placeholder for the task identifier. Since
+   * there is only supported a single supported config identifier ('server') it can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> TASK_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/config/server/tasks/%s"),
+
+          // Task deletion must be tested last
+          RestCall.delete("/config/server/tasks/%s"));
+
+  @Test
+  public void configEndpoints() throws Exception {
+    // 'Access Database' is needed for the '/config/server/check.consistency' REST endpoint
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    execute(CONFIG_ENDPOINTS);
+  }
+
+  @Test
+  public void cacheEndpoints() throws Exception {
+    execute(CACHE_ENDPOINTS, ProjectCacheImpl.CACHE_NAME);
+  }
+
+  @Test
+  public void taskEndpoints() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+
+    Optional<String> id =
+        result
+            .stream()
+            .filter(t -> "Log File Compressor".equals(t.command))
+            .map(t -> t.id)
+            .findFirst();
+    assertThat(id).isPresent();
+
+    execute(TASK_ENDPOINTS, id.get());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java b/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java
new file mode 100644
index 0000000..4de436a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/DeleteOnCollectionIT.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.BranchResource.BRANCH_KIND;
+import static org.apache.http.HttpStatus.SC_NO_CONTENT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Module;
+import org.junit.Test;
+
+public class DeleteOnCollectionIT extends AbstractDaemonTest {
+  @Override
+  public Module createModule() {
+    return new RestApiModule() {
+      @Override
+      public void configure() {
+        deleteOnCollection(BRANCH_KIND)
+            .toInstance(
+                new RestCollectionModifyView<ProjectResource, BranchResource, Object>() {
+                  @Override
+                  public Object apply(ProjectResource parentResource, Object input)
+                      throws Exception {
+                    return Response.none();
+                  }
+                });
+      }
+    };
+  }
+
+  @Test
+  public void deleteOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/projects/" + project.get() + "/branches");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/GroupsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/GroupsRestApiBindingsIT.java
new file mode 100644
index 0000000..4538f75
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/GroupsRestApiBindingsIT.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2018 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.rest;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the groups REST API.
+ *
+ * <p>These tests only verify that the group REST endpoints are correctly bound, they do no test the
+ * functionality of the group REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class GroupsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  /**
+   * Group REST endpoints to be tested, each URL contains a placeholder for the group identifier.
+   */
+  private static final ImmutableList<RestCall> GROUP_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s"),
+          RestCall.put("/groups/%s"),
+          RestCall.get("/groups/%s/detail"),
+          RestCall.get("/groups/%s/name"),
+          RestCall.put("/groups/%s/name"),
+          RestCall.get("/groups/%s/description"),
+          RestCall.put("/groups/%s/description"),
+          RestCall.delete("/groups/%s/description"),
+          RestCall.get("/groups/%s/owner"),
+          RestCall.put("/groups/%s/owner"),
+          RestCall.get("/groups/%s/options"),
+          RestCall.put("/groups/%s/options"),
+          RestCall.post("/groups/%s/members"),
+          RestCall.post("/groups/%s/members.add"),
+          RestCall.post("/groups/%s/members.delete"),
+          RestCall.post("/groups/%s/groups"),
+          RestCall.post("/groups/%s/groups.add"),
+          RestCall.post("/groups/%s/groups.delete"),
+          RestCall.get("/groups/%s/log.audit"),
+          RestCall.post("/groups/%s/index"),
+          RestCall.get("/groups/%s/members"),
+          RestCall.get("/groups/%s/groups"));
+
+  /**
+   * Member REST endpoints to be tested, each URL contains placeholders for the group identifier and
+   * the member identifier.
+   */
+  private static final ImmutableList<RestCall> MEMBER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s/members/%s"),
+          RestCall.put("/groups/%s/members/%s"),
+
+          // Member deletion must be tested last
+          RestCall.delete("/groups/%s/members/%s"));
+
+  /**
+   * Subgroup REST endpoints to be tested, each URL contains placeholders for the group identifier
+   * and the subgroup identifier.
+   */
+  private static final ImmutableList<RestCall> SUBGROUP_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/groups/%s/groups/%s"),
+          RestCall.put("/groups/%s/groups/%s"),
+
+          // Subgroup deletion must be tested last
+          RestCall.delete("/groups/%s/groups/%s"));
+
+  @Test
+  public void groupEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    execute(GROUP_ENDPOINTS, group);
+  }
+
+  @Test
+  public void memberEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    gApi.groups().id(group).addMembers(admin.email);
+    execute(MEMBER_ENDPOINTS, group, admin.email);
+  }
+
+  @Test
+  public void subgroupEndpoints() throws Exception {
+    String group = gApi.groups().create("test-group").get().name;
+    String subgroup = gApi.groups().create("test-subgroup").get().name;
+    gApi.groups().id(group).addGroups(subgroup);
+    execute(SUBGROUP_ENDPOINTS, group, subgroup);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java b/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java
new file mode 100644
index 0000000..06202b1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/MethodNotAllowedIT.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import org.apache.http.client.fluent.Request;
+import org.junit.Test;
+
+public class MethodNotAllowedIT extends AbstractDaemonTest {
+  @Test
+  public void unsupportedPostOnRootCollection() throws Exception {
+    RestResponse response = adminRestSession.post("/accounts/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: POST /accounts/");
+  }
+
+  @Test
+  public void unsupportedPostOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.post("/accounts/self/emails");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: POST /accounts/self/emails");
+  }
+
+  @Test
+  public void unsupportedDeleteOnRootCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/accounts/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: DELETE /accounts/");
+  }
+
+  @Test
+  public void unsupportedDeleteOnChildCollection() throws Exception {
+    RestResponse response = adminRestSession.delete("/accounts/self/emails");
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: DELETE /accounts/self/emails");
+  }
+
+  @Test
+  public void unsupportedHttpMethodOnRootCollection() throws Exception {
+    Request patch = Request.Patch(adminRestSession.url() + "/a/accounts/");
+    RestResponse response = adminRestSession.execute(patch);
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent()).isEqualTo("Not implemented: PATCH /accounts/");
+  }
+
+  @Test
+  public void unsupportedHttpMethodOnChildCollection() throws Exception {
+    Request patch = Request.Patch(adminRestSession.url() + "/a/accounts/self/emails");
+    RestResponse response = adminRestSession.execute(patch);
+    assertThat(response.getStatusCode()).isEqualTo(SC_METHOD_NOT_ALLOWED);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Not implemented: PATCH /accounts/self/emails");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java b/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java
new file mode 100644
index 0000000..e01a461
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/NotFoundIT.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import org.junit.Test;
+
+public class NotFoundIT extends AbstractDaemonTest {
+  @Test
+  public void nonExistingRootCollection() throws Exception {
+    RestResponse response = adminRestSession.get("/non-existing/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not Found");
+
+    response = adminRestSession.post("/non-existing/");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not Found");
+  }
+
+  @Test
+  public void nonExistingView() throws Exception {
+    RestResponse response = adminRestSession.get("/accounts/self/non-existing");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not found: non-existing");
+
+    response = adminRestSession.post("/accounts/self/non-existing");
+    assertThat(response.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    assertThat(response.getEntityContent()).isEqualTo("Not found: non-existing");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java
new file mode 100644
index 0000000..07ea3d0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 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.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
+import com.google.gerrit.extensions.restapi.RawInput;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the plugins REST API.
+ *
+ * <p>These tests only verify that the plugin REST endpoints are correctly bound, they do no test
+ * the functionality of the plugin REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class PluginsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  /**
+   * Plugin REST endpoints to be tested, each URL contains a placeholder for the plugin identifier.
+   */
+  private static final ImmutableList<RestCall> PLUGIN_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.put("/plugins/%s"),
+
+          // For GET requests prefixing the view name with 'gerrit~' is required.
+          RestCall.get("/plugins/%s/gerrit~status"),
+
+          // POST (and PUT) requests don't require the 'gerrit~' prefix in front of the view name.
+          RestCall.post("/plugins/%s/gerrit~enable"),
+          RestCall.post("/plugins/%s/gerrit~disable"),
+          RestCall.post("/plugins/%s/gerrit~reload"),
+
+          // Plugin deletion must be tested last
+          RestCall.delete("/plugins/%s"));
+
+  private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
+  private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginEndpoints() throws Exception {
+    String pluginName = "my-plugin";
+    installPlugin(pluginName);
+    execute(PLUGIN_ENDPOINTS, pluginName);
+  }
+
+  private void installPlugin(String pluginName) throws Exception {
+    InstallPluginInput input = new InstallPluginInput();
+    input.raw = JS_PLUGIN_CONTENT;
+    gApi.plugins().install(pluginName + ".js", input);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
new file mode 100644
index 0000000..6563de3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
@@ -0,0 +1,248 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.GET;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the projects REST API.
+ *
+ * <p>These tests only verify that the project REST endpoints are correctly bound, they do no test
+ * the functionality of the project REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class ProjectsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  private static final ImmutableList<RestCall> PROJECT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s"),
+          RestCall.put("/projects/%s"),
+          RestCall.get("/projects/%s/description"),
+          RestCall.put("/projects/%s/description"),
+          RestCall.delete("/projects/%s/description"),
+          RestCall.get("/projects/%s/parent"),
+          RestCall.put("/projects/%s/parent"),
+          RestCall.get("/projects/%s/config"),
+          RestCall.put("/projects/%s/config"),
+          RestCall.get("/projects/%s/HEAD"),
+          RestCall.put("/projects/%s/HEAD"),
+          RestCall.get("/projects/%s/access"),
+          RestCall.post("/projects/%s/access"),
+          RestCall.put("/projects/%s/access:review"),
+          RestCall.get("/projects/%s/check.access"),
+          RestCall.post("/projects/%s/check.access"),
+          RestCall.put("/projects/%s/ban"),
+          RestCall.get("/projects/%s/statistics.git"),
+          RestCall.post("/projects/%s/index"),
+          RestCall.post("/projects/%s/gc"),
+          RestCall.get("/projects/%s/children"),
+          RestCall.get("/projects/%s/branches"),
+          RestCall.post("/projects/%s/branches:delete"),
+          RestCall.put("/projects/%s/branches/new-branch"),
+          RestCall.get("/projects/%s/tags"),
+          RestCall.post("/projects/%s/tags:delete"),
+          RestCall.put("/projects/%s/tags/new-tag"),
+          RestCall.builder(GET, "/projects/%s/commits")
+              // GET /projects/<project>/branches/<branch>/commits is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/projects/%s/dashboards"));
+
+  /**
+   * Child project REST endpoints to be tested, each URL contains placeholders for the parent
+   * project identifier and the child project identifier.
+   */
+  private static final ImmutableList<RestCall> CHILD_PROJECT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/children/%s"));
+
+  /**
+   * Branch REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the branch identifier.
+   */
+  private static final ImmutableList<RestCall> BRANCH_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/branches/%s"),
+          RestCall.put("/projects/%s/branches/%s"),
+          RestCall.get("/projects/%s/branches/%s/mergeable"),
+          RestCall.builder(GET, "/projects/%s/branches/%s/reflog")
+              // The tests use DfsRepository which does not support getting the reflog.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("reflog not supported on")
+              .build(),
+          RestCall.builder(GET, "/projects/%s/branches/%s/files")
+              // GET /projects/<project>/branches/<branch>/files is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+
+          // Branch deletion must be tested last
+          RestCall.delete("/projects/%s/branches/%s"));
+
+  /**
+   * Branch file REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier, the branch identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> BRANCH_FILE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/branches/%s/files/%s/content"));
+
+  /**
+   * Dashboard REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the dashboard identifier.
+   */
+  private static final ImmutableList<RestCall> DASHBOARD_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/dashboards/%s"),
+          RestCall.put("/projects/%s/dashboards/%s"),
+
+          // Dashboard deletion must be tested last
+          RestCall.delete("/projects/%s/dashboards/%s"));
+
+  /**
+   * Tag REST endpoints to be tested, each URL contains placeholders for the project identifier and
+   * the tag identifier.
+   */
+  private static final ImmutableList<RestCall> TAG_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/tags/%s"),
+          RestCall.put("/projects/%s/tags/%s"),
+          RestCall.delete("/projects/%s/tags/%s"));
+
+  /**
+   * Commit REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the commit identifier.
+   */
+  private static final ImmutableList<RestCall> COMMIT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/commits/%s"),
+          RestCall.get("/projects/%s/commits/%s/in"),
+          RestCall.get("/projects/%s/commits/%s/files"),
+          RestCall.post("/projects/%s/commits/%s/cherrypick"));
+
+  /**
+   * Commit file REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier, the commit identifier and the file identifier.
+   */
+  private static final ImmutableList<RestCall> COMMIT_FILE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/projects/%s/commits/%s/files/%s/content"));
+
+  private static final String FILENAME = "test.txt";
+
+  @Test
+  public void projectEndpoints() throws Exception {
+    execute(PROJECT_ENDPOINTS, project.get());
+  }
+
+  @Test
+  public void childProjectEndpoints() throws Exception {
+    Project.NameKey childProject = createProject("test-child-repo", project);
+    execute(CHILD_PROJECT_ENDPOINTS, project.get(), childProject.get());
+  }
+
+  @Test
+  public void branchEndpoints() throws Exception {
+    execute(BRANCH_ENDPOINTS, project.get(), "master");
+  }
+
+  @Test
+  public void branchFileEndpoints() throws Exception {
+    createAndSubmitChange(FILENAME);
+    execute(BRANCH_FILE_ENDPOINTS, project.get(), "master", FILENAME);
+  }
+
+  @Test
+  public void dashboardEndpoints() throws Exception {
+    createDefaultDashboard();
+    execute(DASHBOARD_ENDPOINTS, project.get(), DEFAULT_DASHBOARD_NAME);
+  }
+
+  @Test
+  public void tagEndpoints() throws Exception {
+    String tag = "test-tag";
+    gApi.projects().name(project.get()).tag(tag).create(new TagInput());
+    execute(TAG_ENDPOINTS, project.get(), tag);
+  }
+
+  @Test
+  public void commitEndpoints() throws Exception {
+    String commit = createAndSubmitChange(FILENAME);
+    execute(COMMIT_ENDPOINTS, project.get(), commit);
+  }
+
+  @Test
+  public void commitFileEndpoints() throws Exception {
+    String commit = createAndSubmitChange(FILENAME);
+    execute(COMMIT_FILE_ENDPOINTS, project.get(), commit, FILENAME);
+  }
+
+  private String createAndSubmitChange(String filename) throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("A change")
+            .parent(getRemoteHead())
+            .add(filename, "content")
+            .insertChangeId()
+            .create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    gApi.changes().id(id).current().submit();
+    return c.name();
+  }
+
+  private void createDefaultDashboard() throws Exception {
+    String dashboardRef = REFS_DASHBOARDS + "team";
+    grant(project, "refs/meta/*", Permission.CREATE);
+    gApi.projects().name(project.get()).branch(dashboardRef).create(new BranchInput());
+
+    try (Repository r = repoManager.openRepository(project)) {
+      TestRepository<Repository>.CommitBuilder cb =
+          new TestRepository<>(r).branch(dashboardRef).commit();
+      StringBuilder content = new StringBuilder("[dashboard]\n");
+      content.append("title = ").append("Open Changes").append("\n");
+      content.append("[section \"").append("open").append("\"]\n");
+      content.append("query = ").append("is:open").append("\n");
+      cb.add("overview", content.toString());
+      cb.create();
+    }
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setLocalDefaultDashboard(dashboardRef + ":overview");
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java
new file mode 100644
index 0000000..a2c4ea6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.GET;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GerritConfig;
+import org.junit.Test;
+
+/**
+ * Tests for checking the bindings of the root REST API.
+ *
+ * <p>These tests only verify that the root REST endpoints are correctly bound, they do no test the
+ * functionality of the root REST endpoints (for details see JavaDoc on {@link
+ * AbstractRestApiBindingsTest}).
+ */
+public class RootCollectionsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  /** Root REST endpoints to be tested, the URLs contain no placeholders. */
+  private static final ImmutableList<RestCall> ROOT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/access/"),
+          RestCall.get("/accounts/"),
+          RestCall.put("/accounts/new-account"),
+          RestCall.builder(GET, "/config/")
+              // GET /config/ is not implemented
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/changes/"),
+          RestCall.post("/changes/"),
+          RestCall.get("/groups/"),
+          RestCall.put("/groups/new-group"),
+          RestCall.get("/plugins/"),
+          RestCall.put("/plugins/new-plugin"),
+          RestCall.get("/projects/"),
+          RestCall.put("/projects/new-project"));
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void rootEndpoints() throws Exception {
+    execute(ROOT_ENDPOINTS);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
new file mode 100644
index 0000000..137dc21
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -0,0 +1,299 @@
+// Copyright (C) 2018 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.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_CREATED;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.Expect;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.httpd.restapi.ParameterParser;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class TraceIT extends AbstractDaemonTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+  @Inject private WorkQueue workQueue;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TraceValidatingCommitValidationListener commitValidationListener;
+  private RegistrationHandle commitValidationRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+    commitValidationListener = new TraceValidatingCommitValidationListener();
+    commitValidationRegistrationHandle =
+        commitValidationListeners.add("gerrit", commitValidationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+    commitValidationRegistrationHandle.remove();
+  }
+
+  @Test
+  public void restCallWithoutTrace() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new1");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParam() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParamAndProvidedTraceId() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=issue/123");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeader() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeaderAndProvidedTraceId() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParamAndTraceHeader() throws Exception {
+    // trace ID only specified by trace header
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // trace ID only specified by trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new7?trace=issue/123", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // same trace ID specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new8?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // different trace IDs specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new9?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeaders(RestApiServlet.X_GERRIT_TRACE))
+        .containsExactly("issue/123", "issue/456");
+    assertThat(projectCreationListener.traceIds).containsExactly("issue/123", "issue/456");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void pushWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=issue/123"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void pushForReviewWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=issue/123"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void workQueueCopyLoggingContext() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+
+      workQueue
+          .createQueue(1, "test-queue")
+          .submit(
+              () -> {
+                // Verify that the tags and force logging flag have been propagated to the new
+                // thread.
+                SortedMap<String, SortedSet<Object>> threadTagMap =
+                    LoggingContext.getInstance().getTags().asMap();
+                expect.that(threadTagMap.keySet()).containsExactly("foo");
+                expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                expect
+                    .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+                    .isTrue();
+              })
+          .get();
+
+      // Verify that tags and force logging flag in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    String traceId;
+    ImmutableSet<String> traceIds;
+    Boolean isLoggingForced;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.traceIds = LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID");
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+    }
+  }
+
+  private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
+    String traceId;
+    Boolean isLoggingForced;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
new file mode 100644
index 0000000..aca6c4c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 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.rest.account;
+
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import org.junit.Test;
+
+public class CreateAccountIT extends AbstractDaemonTest {
+  @Test
+  public void createAccountRestApi() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    assertThat(accountCache.getByUsername(input.username)).isEmpty();
+    RestResponse r = adminRestSession.put("/accounts/" + input.username, input);
+    r.assertCreated();
+    assertThat(accountCache.getByUsername(input.username)).isPresent();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index f031729..b74a0d7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -60,7 +60,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
@@ -105,8 +104,6 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
@@ -133,8 +130,6 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
@@ -927,7 +922,7 @@
   private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
@@ -980,7 +975,7 @@
 
   private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
     assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
-    assertThat(update.getMessage()).isEqualTo(msg);
+    assertThat(update.getMessage()).contains(msg);
   }
 
   private AutoCloseable createFailOnLoadContext() {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index 0419933..07bc394 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -19,8 +19,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.restapi.account.GetDetail.AccountDetailInfo;
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index e6f61fa..65c95f8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -189,7 +189,6 @@
     testVoteOnBehalfOfWithComment();
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
@@ -225,7 +224,6 @@
     assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithRobotComment() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
@@ -474,7 +472,7 @@
     in.drafts = DraftHandling.PUBLISH;
     RestResponse res =
         adminRestSession.postWithHeader(
-            "/changes/" + r.getChangeId() + "/revisions/current/review", in, runAsHeader(user.id));
+            "/changes/" + r.getChangeId() + "/revisions/current/review", runAsHeader(user.id), in);
     res.assertOK();
 
     ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
@@ -506,13 +504,13 @@
     in.message = "Message on behalf of";
 
     String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
-    RestResponse res = adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
+    RestResponse res = adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id), in);
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
 
     in.label("Code-Review", 1);
-    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id)).assertOK();
+    adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id), in).assertOK();
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.getPatchSetId().get()).isEqualTo(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 61a2d84..af11149 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -27,6 +27,7 @@
 import static java.util.stream.Collectors.toList;
 
 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;
@@ -154,11 +155,12 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    assertThat(getRemoteHead()).isNull();
     PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
     Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmitPreview = getRemoteHead();
-    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(headAfterSubmitPreview).isNull();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
@@ -1070,6 +1072,45 @@
     change.current().submit();
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Change 1", ImmutableMap.of())
+            .to("refs/for/master");
+    change.assertOkStatus();
+    // TODO(dborowitz): Use EMPTY_TREE_ID after upgrading to https://git.eclipse.org/r/127473
+    assertThat(change.getCommit().getTree())
+        .isEqualTo(ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904"));
+
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
@@ -1283,7 +1324,7 @@
 
   protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
     assertThat(onSubmitValidatorHandle).isNull();
-    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
+    onSubmitValidatorHandle = onSubmitValidationListeners.add("gerrit", listener);
   }
 
   private String getLatestDiff(Repository repo) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 171babd..f45f9dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -331,7 +331,7 @@
     assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     Map<String, ActionInfo> newActions =
         gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
@@ -380,7 +380,7 @@
     assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     // Test different codepaths within ActionJson...
     // ...via revision API.
@@ -443,7 +443,7 @@
     assertThat(origActions.get("description").label).isEqualTo("Edit Description");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     // Unlike for the current revision, actions for old revisions are only available via the
     // revision API.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/BUILD b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
index 6a4b4a7..46eb0ba 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
@@ -15,6 +15,7 @@
     labels = ["rest"],
     deps = [
         ":submit_util",
+        "//java/com/google/gerrit/mail",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index d0b01b7..790b884 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -11,26 +11,42 @@
 // 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.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.util.RawParseUtils.decode;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
+import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -114,14 +130,85 @@
   public void getOneChangeMessage() throws Exception {
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     List<ChangeMessageInfo> messages = new ArrayList<>(gApi.changes().id(changeNum).get().messages);
-
     for (ChangeMessageInfo messageInfo : messages) {
       String id = messageInfo.id;
       assertThat(gApi.changes().id(changeNum).message(id).get()).isEqualTo(messageInfo);
     }
   }
 
+  @Test
+  public void deleteCannotBeAppliedWithoutAdministrateServerCapability() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    setApiUser(user);
+
+    try {
+      deleteOneChangeMessage(changeNum, 0, user, "spam");
+      fail("expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e.getMessage()).isEqualTo("administrate server not permitted");
+    }
+  }
+
+  @Test
+  public void deleteCanBeAppliedWithAdministrateServerCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    setApiUser(user);
+    deleteOneChangeMessage(changeNum, 0, user, "spam");
+  }
+
+  @Test
+  public void deleteCannotBeAppliedWithEmptyChangeMessageUuid() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    try {
+      gApi.changes().id(changeId).message("").delete(new DeleteChangeMessageInput("spam"));
+      fail("expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      assertThat(e.getMessage()).isEqualTo("change message  not found");
+    }
+  }
+
+  @Test
+  public void deleteCannotBeAppliedWithNonExistingChangeMessageUuid() throws Exception {
+    String changeId = createChange().getChangeId();
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput();
+    String id = "8473b95934b5732ac55d26311a706c9c2bde9941";
+    input.reason = "spam";
+
+    try {
+      gApi.changes().id(changeId).message(id).delete(input);
+      fail("expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      assertThat(e.getMessage()).isEqualTo(String.format("change message %s not found", id));
+    }
+  }
+
+  @Test
+  public void deleteCanBeAppliedWithoutProvidingReason() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    deleteOneChangeMessage(changeNum, 2, admin, "");
+  }
+
+  @Test
+  public void deleteOneChangeMessageTwice() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    // Deletes the second change message twice.
+    deleteOneChangeMessage(changeNum, 1, admin, "reason 1");
+    deleteOneChangeMessage(changeNum, 1, admin, "reason 2");
+  }
+
+  @Test
+  public void deleteMultipleChangeMessages() throws Exception {
+    int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
+    for (int i = 0; i < 7; ++i) {
+      deleteOneChangeMessage(changeNum, i, admin, "reason " + i);
+    }
+  }
+
   private int createOneChangeWithMultipleChangeMessagesInHistory() throws Exception {
+    // Creates the following commit history on the meta branch of the test change.
+
     setApiUser(user);
     // Commit 1: create a change.
     PushOneCommit.Result result = createChange();
@@ -158,6 +245,150 @@
     gApi.changes().id(changeId).current().review(reviewInput);
   }
 
+  private void deleteOneChangeMessage(
+      int changeNum, int deletedMessageIndex, TestAccount deletedBy, String reason)
+      throws Exception {
+    List<ChangeMessageInfo> messagesBeforeDeletion = gApi.changes().id(changeNum).messages();
+
+    List<CommentInfo> commentsBefore = getChangeSortedComments(changeNum);
+    List<RevCommit> commitsBefore = new ArrayList<>();
+    if (notesMigration.readChanges()) {
+      commitsBefore = getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    }
+
+    String id = messagesBeforeDeletion.get(deletedMessageIndex).id;
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput(reason);
+    ChangeMessageInfo info = gApi.changes().id(changeNum).message(id).delete(input);
+
+    // Verify the return change message info is as expect.
+    assertThat(info.message).isEqualTo(createNewChangeMessage(deletedBy.fullName, reason));
+    List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
+    assertMessagesAfterDeletion(
+        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, deletedBy, reason);
+    assertCommentsAfterDeletion(changeNum, commentsBefore);
+
+    // Verify change index is updated after deletion.
+    List<ChangeInfo> changes = gApi.changes().query("message removed").get();
+    assertThat(changes.stream().map(c -> c._number).collect(toSet())).contains(changeNum);
+
+    // Verifies states of commits if NoteDb is on.
+    if (notesMigration.readChanges()) {
+      assertMetaCommitsAfterDeletion(
+          commitsBefore, changeNum, deletedMessageIndex, deletedBy, reason);
+    }
+  }
+
+  private void assertMessagesAfterDeletion(
+      List<ChangeMessageInfo> messagesBeforeDeletion,
+      List<ChangeMessageInfo> messagesAfterDeletion,
+      int deletedMessageIndex,
+      TestAccount deletedBy,
+      String deleteReason) {
+    assertThat(messagesAfterDeletion)
+        .named("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+        .hasSize(messagesBeforeDeletion.size());
+
+    for (int i = 0; i < messagesAfterDeletion.size(); ++i) {
+      ChangeMessageInfo before = messagesBeforeDeletion.get(i);
+      ChangeMessageInfo after = messagesAfterDeletion.get(i);
+
+      if (i < deletedMessageIndex) {
+        // The uuid of a commit message will be updated after rewriting.
+        assertThat(after.id).isEqualTo(before.id);
+      }
+
+      assertThat(after.tag).isEqualTo(before.tag);
+      assertThat(after.author).isEqualTo(before.author);
+      assertThat(after.realAuthor).isEqualTo(before.realAuthor);
+      assertThat(after._revisionNumber).isEqualTo(before._revisionNumber);
+
+      if (i == deletedMessageIndex) {
+        assertThat(after.message)
+            .isEqualTo(createNewChangeMessage(deletedBy.fullName, deleteReason));
+      } else {
+        assertThat(after.message).isEqualTo(before.message);
+      }
+    }
+  }
+
+  private void assertMetaCommitsAfterDeletion(
+      List<RevCommit> commitsBeforeDeletion,
+      int changeNum,
+      int deletedMessageIndex,
+      TestAccount deletedBy,
+      String deleteReason)
+      throws Exception {
+    List<RevCommit> commitsAfterDeletion =
+        getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    assertThat(commitsAfterDeletion).hasSize(commitsBeforeDeletion.size());
+
+    for (int i = 0; i < commitsBeforeDeletion.size(); i++) {
+      RevCommit commitBefore = commitsBeforeDeletion.get(i);
+      RevCommit commitAfter = commitsAfterDeletion.get(i);
+      if (i == deletedMessageIndex) {
+        byte[] rawBefore = commitBefore.getRawBuffer();
+        byte[] rawAfter = commitAfter.getRawBuffer();
+        Charset encodingBefore = RawParseUtils.parseEncoding(rawBefore);
+        Charset encodingAfter = RawParseUtils.parseEncoding(rawAfter);
+        Optional<ChangeNoteUtil.CommitMessageRange> rangeBefore =
+            parseCommitMessageRange(commitBefore);
+        Optional<ChangeNoteUtil.CommitMessageRange> rangeAfter =
+            parseCommitMessageRange(commitAfter);
+        assertThat(rangeBefore.isPresent()).isTrue();
+        assertThat(rangeAfter.isPresent()).isTrue();
+
+        String subjectBefore =
+            decode(
+                encodingBefore,
+                rawBefore,
+                rangeBefore.get().subjectStart(),
+                rangeBefore.get().subjectEnd());
+        String subjectAfter =
+            decode(
+                encodingAfter,
+                rawAfter,
+                rangeAfter.get().subjectStart(),
+                rangeAfter.get().subjectEnd());
+        assertThat(subjectBefore).isEqualTo(subjectAfter);
+
+        String footersBefore =
+            decode(
+                encodingBefore,
+                rawBefore,
+                rangeBefore.get().changeMessageEnd() + 1,
+                rawBefore.length);
+        String footersAfter =
+            decode(
+                encodingAfter, rawAfter, rangeAfter.get().changeMessageEnd() + 1, rawAfter.length);
+        assertThat(footersBefore).isEqualTo(footersAfter);
+
+        String message =
+            decode(
+                encodingAfter,
+                rawAfter,
+                rangeAfter.get().changeMessageStart(),
+                rangeAfter.get().changeMessageEnd() + 1);
+        assertThat(message).isEqualTo(createNewChangeMessage(deletedBy.fullName, deleteReason));
+      } else {
+        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
+      }
+
+      assertThat(commitAfter.getCommitterIdent().getName())
+          .isEqualTo(commitBefore.getCommitterIdent().getName());
+      assertThat(commitAfter.getAuthorIdent().getName())
+          .isEqualTo(commitBefore.getAuthorIdent().getName());
+      assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
+      assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
+    }
+  }
+
+  /** Verifies comments are not changed after deleting change message(s). */
+  private void assertCommentsAfterDeletion(int changeNum, List<CommentInfo> commentsBeforeDeletion)
+      throws Exception {
+    List<CommentInfo> commentsAfterDeletion = getChangeSortedComments(changeNum);
+    assertThat(commentsAfterDeletion).containsExactlyElementsIn(commentsBeforeDeletion).inOrder();
+  }
+
   private static void assertMessage(String expected, String actual) {
     assertThat(actual).isEqualTo("Patch Set 1:\n\n" + expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 84c9c03..06b67d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import java.util.List;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 129d98a..2259999 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -46,8 +46,8 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.restapi.change.PostReviewers;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index c723082..9218336 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -101,9 +101,7 @@
     ChangeInput ci = newChangeInput(ChangeStatus.NEW);
     ci.subject = "Subject\n\nChange-Id: I0000000000000000000000000000000000000000";
     assertCreateFails(
-        ci,
-        ResourceConflictException.class,
-        "invalid Change-Id line format in commit message footer");
+        ci, ResourceConflictException.class, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -113,7 +111,7 @@
     assertCreateFails(
         ci,
         ResourceConflictException.class,
-        "missing subject; Change-Id must be in commit message footer");
+        "missing subject; Change-Id must be in message footer");
   }
 
   @Test
@@ -289,9 +287,7 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index be0879a..206cc68 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -29,12 +29,14 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.Util;
+import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -292,6 +294,44 @@
         .containsExactly((short) -2, (short) -1, (short) 0, (short) 0);
   }
 
+  @Test
+  public void moveToBranchWithoutLabel() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+    String testLabelA = "Label-A";
+    configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
+
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/master");
+      u.save();
+    }
+
+    String changeId = createChange().getChangeId();
+
+    ReviewInput input = new ReviewInput();
+    input.label(testLabelA, -1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().keySet())
+        .containsExactly(testLabelA);
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+        .containsExactly((short) -1);
+
+    move(changeId, "foo");
+
+    // TODO(dpursehouse): Assert about state of labels after move
+  }
+
+  @Test
+  public void moveNoDestinationBranchSpecified() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("destination branch is required");
+    move(r.getChangeId(), null);
+  }
+
   private void move(int changeNum, String destination) throws RestApiException {
     gApi.changes().id(changeNum).move(destination);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 8cd1770..2182b2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -97,6 +97,7 @@
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index e8b8fe8..3d8d06e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -87,6 +87,7 @@
 
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 7657e2e..4d499f0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -42,7 +43,7 @@
 import org.junit.Test;
 
 public class SuggestReviewersIT extends AbstractDaemonTest {
-  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private CreateGroup createGroup;
 
   private InternalGroup group1;
   private InternalGroup group2;
@@ -490,7 +491,8 @@
   }
 
   private InternalGroup newGroup(String name) throws Exception {
-    GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
+    GroupInfo group =
+        createGroup.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(name(name)), null);
     return group(new AccountGroup.UUID(group.id));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java b/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
index 3121812..1d928a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -36,4 +38,24 @@
     response = adminRestSession.put(endpoint, "");
     response.assertNoContent();
   }
+
+  @Test
+  public void leadingAndTrailingWhitespaceGetsSanitized() throws Exception {
+    Result result = createChange();
+    String endpoint = "/changes/" + result.getChangeId() + "/topic";
+    RestResponse response = adminRestSession.put(endpoint, "\t \t topic\t ");
+    response.assertOK();
+
+    assertThat(response.getEntityContent()).isEqualTo(")]}'\n\"topic\"");
+  }
+
+  @Test
+  public void containedWhitespaceDoesNotGetSanitized() throws Exception {
+    Result result = createChange();
+    String endpoint = "/changes/" + result.getChangeId() + "/topic";
+    RestResponse response = adminRestSession.put(endpoint, "t opic");
+    response.assertOK();
+
+    assertThat(response.getEntityContent()).isEqualTo(")]}'\n\"t opic\"");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
new file mode 100644
index 0000000..34d87d0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2018 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.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class WorkInProgressByDefaultIT extends AbstractDaemonTest {
+  private Project.NameKey project1;
+  private Project.NameKey project2;
+
+  @Before
+  public void setUp() throws Exception {
+    project1 = createProject("project-1");
+    project2 = createProject("project-2", project1);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    setApiUser(admin);
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.workInProgressByDefault = false;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectInherited() throws Exception {
+    setWorkInProgressByDefaultForProject(project1);
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    assertThat(
+            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    assertThat(
+            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isFalse();
+  }
+
+  @Test
+  public void pushWorkInProgressByDefaultForProjectInherited() throws Exception {
+    setWorkInProgressByDefaultForProject(project1);
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  private void setWorkInProgressByDefaultForProject(Project.NameKey p) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(p.get()).config(input);
+  }
+
+  private void setWorkInProgressByDefaultForUser() throws Exception {
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p) throws Exception {
+    return createChange(p, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p, String r) throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(p);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to(r);
+    result.assertOkStatus();
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 65ed7e4..7ef915b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -31,14 +31,17 @@
 
   @Test
   public void flushAll() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.postOK("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r = adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r.assertOK();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
   }
@@ -59,25 +62,30 @@
 
   @Test
   public void flush() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
 
     r =
-        adminRestSession.postOK(
+        adminRestSession.post(
             "/config/server/caches/",
             new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
+    r.assertOK();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
   }
@@ -96,7 +104,8 @@
 
   @Test
   public void flush_UnprocessableEntity() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/projects");
+    RestResponse r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
@@ -107,7 +116,8 @@
     r.assertUnprocessableEntity();
     r.consume();
 
-    r = adminRestSession.getOK("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
   }
@@ -118,8 +128,9 @@
         REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
     try {
       RestResponse r =
-          userRestSession.postOK(
+          userRestSession.post(
               "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+      r.assertOK();
       r.consume();
 
       userRestSession
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 6c779f5..874e04e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -22,10 +22,10 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
@@ -115,9 +115,6 @@
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
     assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
 
-    // Acceptance tests force --headless even when UIs are specified in config.
-    assertThat(i.gerrit.webUis).isEmpty();
-
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index f7903dd..a64305c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -89,6 +89,7 @@
   public void webLink() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
+            "gerrit",
             new FileHistoryWebLink() {
               @Override
               public WebLinkInfo getFileHistoryWebLink(
@@ -111,6 +112,7 @@
   public void webLinkNoRefsMetaConfig() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
+            "gerrit",
             new FileHistoryWebLink() {
               @Override
               public WebLinkInfo getFileHistoryWebLink(
@@ -649,6 +651,34 @@
     assertThat(permissions2.keySet()).containsExactly(Permission.READ);
   }
 
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid Name: " + invalidRef);
+    pApi().access(accessInput);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid Name: " + invalidRef);
+    pApi().accessChange(accessInput);
+  }
+
   private ProjectApi pApi() throws Exception {
     return gApi.projects().name(newProjectName.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 19f6295..1eea84b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -46,7 +46,7 @@
         pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 48dc994..df89686 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -35,7 +37,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class CreateBranchIT extends AbstractDaemonTest {
   private Branch.NameKey testBranch;
 
@@ -45,9 +46,22 @@
   }
 
   @Test
+  public void createBranchRestApi() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "foo";
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .doesNotContain(REFS_HEADS + input.ref);
+    RestResponse r =
+        adminRestSession.put("/projects/" + project.get() + "/branches/" + input.ref, input);
+    r.assertCreated();
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .contains(REFS_HEADS + input.ref);
+  }
+
+  @Test
   public void createBranch_Forbidden() throws Exception {
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -71,7 +85,7 @@
   @Test
   public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -79,7 +93,7 @@
     grantOwner();
     blockCreateReference();
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -142,12 +156,12 @@
       Class<? extends RestApiException> errType,
       String errMsg)
       throws Exception {
-    exception.expect(errType);
+    BranchInput in = new BranchInput();
+    in.revision = revision;
     if (errMsg != null) {
       exception.expectMessage(errMsg);
     }
-    BranchInput in = new BranchInput();
-    in.revision = revision;
+    exception.expect(errType);
     branch(branch).create(in);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5e1b0bf..b426a37 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -193,7 +193,7 @@
   private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     branch(branch).delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 008997b..c1bd8f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -28,6 +28,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.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -64,7 +65,24 @@
   }
 
   @Test
-  public void deleteBranchesForbidden() throws Exception {
+  public void deleteOneBranchWithoutPermissionForbidden() throws Exception {
+    ImmutableList<String> branchToDelete = ImmutableList.of("refs/heads/test-1");
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = branchToDelete;
+    setApiUser(user);
+    try {
+      project().deleteBranches(input);
+      fail("Expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
+    }
+    setApiUser(admin);
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteMultiBranchesWithoutPermissionForbidden() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = BRANCHES;
     setApiUser(user);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 0cbbe44..3ae0b44 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -125,7 +125,7 @@
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     tag().delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
new file mode 100644
index 0000000..3ac2d10
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2018 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.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.util.Map;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ListCommitFilesIT extends AbstractDaemonTest {
+  private static String SUBJECT_1 = "subject 1";
+  private static String SUBJECT_2 = "subject 2";
+  private static String FILE_A = "a.txt";
+  private static String FILE_B = "b.txt";
+
+  @Test
+  public void listCommitFiles() throws Exception {
+    commitBuilder().add(FILE_B, "2").message(SUBJECT_1).create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    RevCommit a = commitBuilder().add(FILE_A, "1").rm(FILE_B).message(SUBJECT_2).create();
+    String id = getChangeId(testRepo, a).get();
+    pushHead(testRepo, "refs/for/master", false);
+
+    RestResponse r =
+        userRestSession.get("/projects/" + project.get() + "/commits/" + a.name() + "/files/");
+    r.assertOK();
+    Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+    Map<String, FileInfo> files1 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    r = userRestSession.get("/changes/" + id + "/revisions/" + a.name() + "/files");
+    r.assertOK();
+    Map<String, FileInfo> files2 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    assertThat(files1).isEqualTo(files2);
+  }
+
+  @Test
+  public void listMergeCommitFiles() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master");
+
+    RestResponse r =
+        userRestSession.get(
+            "/projects/"
+                + project.get()
+                + "/commits/"
+                + result.getCommit().name()
+                + "/files/?parent=2");
+    r.assertOK();
+    Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+    Map<String, FileInfo> files1 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    r =
+        userRestSession.get(
+            "/changes/"
+                + result.getChangeId()
+                + "/revisions/"
+                + result.getCommit().name()
+                + "/files/?parent=2");
+    r.assertOK();
+    Map<String, FileInfo> files2 = newGson().fromJson(r.getReader(), type);
+    r.consume();
+
+    assertThat(files1).isEqualTo(files2);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 714751d..d4edc0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -254,7 +254,7 @@
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
-    exception.expectMessage("create not permitted");
+    exception.expectMessage("not permitted: create");
     tag(input.ref).create(input);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 9621c16..8b1f4bc 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -58,26 +57,21 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -634,13 +628,13 @@
                 + url
                 + "#/c/"
                 + c
-                + "/1/a.txt\n"
+                + "/1/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
                 + "#/c/"
                 + c
-                + "/1/a.txt@a2\n"
+                + "/1/a.txt@a2 \n"
                 + "PS1, Line 2: \n"
                 + "what happened to this?\n"
                 + "\n"
@@ -648,7 +642,7 @@
                 + url
                 + "#/c/"
                 + c
-                + "/1/a.txt@1\n"
+                + "/1/a.txt@1 \n"
                 + "PS1, Line 1: ew\n"
                 + "nit: trailing whitespace\n"
                 + "\n"
@@ -656,13 +650,13 @@
                 + url
                 + "#/c/"
                 + c
-                + "/2/a.txt\n"
+                + "/2/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
                 + "#/c/"
                 + c
-                + "/2/a.txt@a1\n"
+                + "/2/a.txt@a1 \n"
                 + "PS2, Line 1: \n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -670,7 +664,7 @@
                 + url
                 + "#/c/"
                 + c
-                + "/2/a.txt@a2\n"
+                + "/2/a.txt@a2 \n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -678,7 +672,7 @@
                 + url
                 + "#/c/"
                 + c
-                + "/2/a.txt@1\n"
+                + "/2/a.txt@1 \n"
                 + "PS2, Line 1: ew\n"
                 + "join lines\n"
                 + "\n"
@@ -686,7 +680,7 @@
                 + url
                 + "#/c/"
                 + c
-                + "/2/a.txt@2\n"
+                + "/2/a.txt@2 \n"
                 + "PS2, Line 2: nten\n"
                 + "typo: content\n"
                 + "\n"
@@ -838,7 +832,7 @@
     CommentInput c9 = newComment("b.txt", "comment 9");
     addComments(changeId, ps2, c9);
 
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(9);
     // PS1 has comments [c1, c2, c3, c4, c5].
     assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
@@ -853,7 +847,7 @@
     for (int i = 0; i < commentsBeforeDelete.size(); i++) {
       List<RevCommit> commitsBeforeDelete = new ArrayList<>();
       if (notesMigration.commitChangeWrites()) {
-        commitsBeforeDelete = getCommits(id);
+        commitsBeforeDelete = getChangeMetaCommitsInReverseOrder(id);
       }
 
       CommentInfo comment = commentsBeforeDelete.get(i);
@@ -879,7 +873,7 @@
 
       comment.message = expectedMsg;
       commentsBeforeDelete.set(i, comment);
-      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
+      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(id.get());
       assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
     }
 
@@ -893,7 +887,7 @@
     addComments(changeId, ps3, c12);
     addComments(changeId, ps4, c13);
 
-    assertThat(getChangeSortedComments(changeId)).hasSize(13);
+    assertThat(getChangeSortedComments(id.get())).hasSize(13);
     assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
     assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
     assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
@@ -914,7 +908,7 @@
     addComments(changeId, ps1, c2);
     addComments(changeId, ps1, c3);
 
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(3);
     Optional<CommentInfo> targetComment =
         commentsBeforeDelete.stream().filter(c -> c.message.equals("comment 2")).findFirst();
@@ -924,7 +918,7 @@
 
     List<RevCommit> commitsBeforeDelete = new ArrayList<>();
     if (notesMigration.commitChangeWrites()) {
-      commitsBeforeDelete = getCommits(id);
+      commitsBeforeDelete = getChangeMetaCommitsInReverseOrder(id);
     }
 
     setApiUser(admin);
@@ -944,14 +938,12 @@
     if (notesMigration.commitChangeWrites()) {
       assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
     }
-    assertThat(getChangeSortedComments(changeId)).hasSize(3);
+    assertThat(getChangeSortedComments(id.get())).hasSize(3);
   }
 
   @Test
   public void jsonCommentHasLegacyFormatFalse() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isTrue();
-
     PushOneCommit.Result result = createChange();
     Change.Id changeId = result.getChange().getId();
     addComment(result.getChangeId(), "comment");
@@ -964,19 +956,6 @@
     assertThat(comment.legacyFormat).isFalse();
   }
 
-  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
-    List<CommentInfo> comments = new ArrayList<>();
-    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
-    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
-      for (CommentInfo c : e.getValue()) {
-        c.path = e.getKey(); // Set the comment's path field.
-        comments.add(c);
-      }
-    }
-    comments.sort(Comparator.comparing(c -> c.id));
-    return comments;
-  }
-
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId)
         .values()
@@ -1000,15 +979,6 @@
     gApi.changes().id(changeId).revision(revision).review(input);
   }
 
-  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
-      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
-      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
-      return Lists.newArrayList(revWalk);
-    }
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1019,7 +989,7 @@
       String targetCommentUuid,
       String expectedMessage)
       throws Exception {
-    List<RevCommit> afterDelete = getCommits(changeId);
+    List<RevCommit> afterDelete = getChangeMetaCommitsInReverseOrder(changeId);
     assertThat(afterDelete).hasSize(beforeDelete.size());
 
     try (Repository repo = repoManager.openRepository(project);
@@ -1121,10 +1091,6 @@
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
-  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments();
-  }
-
   private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index ea44bd7..29c043a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -902,9 +902,7 @@
     }
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
+        noteUtil.newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
deleted file mode 100644
index e029e7a..0000000
--- a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2017 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.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Inject;
-import java.util.Collection;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class LegacyCommentsIT extends AbstractDaemonTest {
-  @Inject private ChangeNoteUtil noteUtil;
-
-  @ConfigSuite.Default
-  public static Config writeJsonFalseConfig() {
-    Config c = new Config();
-    c.setBoolean("noteDb", null, "writeJson", false);
-    return c;
-  }
-
-  @Before
-  public void setUp() {
-    setApiUser(user);
-  }
-
-  @Test
-  public void legacyCommentHasLegacyFormatTrue() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isFalse();
-
-    PushOneCommit.Result result = createChange();
-    Change.Id changeId = result.getChange().getId();
-
-    CommentInput cin = new CommentInput();
-    cin.message = "comment";
-    cin.path = PushOneCommit.FILE_NAME;
-
-    ReviewInput rin = new ReviewInput();
-    rin.comments = ImmutableMap.of(cin.path, ImmutableList.of(cin));
-    gApi.changes().id(changeId.get()).current().review(rin);
-
-    Collection<Comment> comments =
-        notesFactory.createChecked(db, project, changeId).getComments().values();
-    assertThat(comments).hasSize(1);
-    Comment comment = comments.iterator().next();
-    assertThat(comment.message).isEqualTo("comment");
-    assertThat(comment.legacyFormat).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index cefde21..ae51d1ec 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -42,6 +42,7 @@
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.ObjectId;
@@ -189,6 +190,29 @@
   }
 
   @Test
+  public void harmfulMutationsOfEditsAreNotPossibleForPatchListEntry() throws Exception {
+    RevCommit commit =
+        commitBuilder().add("a.txt", "First line\nSecond line\n").message(SUBJECT_1).create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PatchListKey diffKey = PatchListKey.againstDefaultBase(commit.copy(), Whitespace.IGNORE_NONE);
+    PatchList patchList = patchListCache.get(diffKey, project);
+
+    PatchListEntry patchListEntry = getEntryFor(patchList, "a.txt");
+    Edit outputEdit = Iterables.getOnlyElement(patchListEntry.getEdits());
+    Edit originalEdit =
+        new Edit(
+            outputEdit.getBeginA(),
+            outputEdit.getEndA(),
+            outputEdit.getBeginB(),
+            outputEdit.getEndB());
+
+    outputEdit.shift(5);
+
+    assertThat(patchListEntry.getEdits()).containsExactly(originalEdit);
+  }
+
+  @Test
   public void harmfulMutationsOfEditsAreNotPossibleForIntraLineDiffArgsAndCachedValue() {
     String a = "First line\nSecond line\n";
     String b = "1st line\n2nd line\n";
@@ -269,4 +293,15 @@
   private ObjectId getCurrentRevisionId(String changeId) throws Exception {
     return ObjectId.fromString(gApi.changes().id(changeId).get().currentRevision);
   }
+
+  private static PatchListEntry getEntryFor(PatchList patchList, String filePath) {
+    Optional<PatchListEntry> patchListEntry =
+        patchList
+            .getPatches()
+            .stream()
+            .filter(entry -> entry.getNewName().equals(filePath))
+            .findAny();
+    return patchListEntry.orElseThrow(
+        () -> new IllegalStateException("No PatchListEntry for " + filePath + " exists"));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 14b3858..f4a833f 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -76,6 +76,7 @@
 
     eventListenerRegistration =
         source.add(
+            "gerrit",
             new CommentAddedListener() {
               @Override
               public void onCommentAdded(Event event) {
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index 32f1ce5..c1b5cf4 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 import java.time.Instant;
 import java.util.HashMap;
 import org.junit.Ignore;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/BUILD b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
index 4175272..b5ad425 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
@@ -3,6 +3,7 @@
 DEPS = [
     "//lib/greenmail",
     "//lib/mail",
+    "//java/com/google/gerrit/mail",
 ]
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 7096581..9645a94 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -42,10 +42,14 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.PostReview;
@@ -970,6 +974,38 @@
   }
 
   @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    setWorkInProgressByDefault(project, InheritableBoolean.TRUE);
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForUser() throws Exception {
+    // Make sure owner user is created
+    StagedChange sc = stageReviewableChange();
+    // All was cleaned already
+    assertThat(sender).notSent();
+
+    // Toggle workInProgress flag for owner
+    GeneralPreferencesInfo prefs = gApi.accounts().id(sc.owner.id.get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(sc.owner.id.get()).setPreferences(prefs);
+
+    // Create another change without notification that should be wip
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).notSent();
+
+    // Clean up workInProgressByDefault by owner
+    prefs = gApi.accounts().id(sc.owner.id.get()).getPreferences();
+    Truth.assertThat(prefs.workInProgressByDefault).isTrue();
+    prefs.workInProgressByDefault = false;
+    gApi.accounts().id(sc.owner.id.get()).setPreferences(prefs);
+  }
+
+  @Test
   public void createReviewableChangeWithNotifyOwnerReviewers() throws Exception {
     stagePreChange("refs/for/master%notify=OWNER_REVIEWERS");
     assertThat(sender).notSent();
@@ -2646,4 +2682,11 @@
     // PolyGerrit current immediately follows up with a review.
     gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.noScore());
   }
+
+  private void setWorkInProgressByDefault(Project.NameKey p, InheritableBoolean v)
+      throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = v;
+    gApi.projects().name(p.get()).config(input);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 438954c..13f0416 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
 import com.google.inject.Inject;
 import java.time.ZoneId;
@@ -100,7 +100,7 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 3315a33..a0170d2 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -22,8 +22,9 @@
 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.mail.EmailHeader;
+import com.google.gerrit.mail.MailProcessingUtil;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
@@ -63,7 +64,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
 
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
@@ -100,7 +101,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
     expectedHeaders.put(
@@ -147,7 +148,7 @@
             .contains(
                 entry.getKey()
                     + ": "
-                    + MailUtil.rfcDateformatter.format(
+                    + MailProcessingUtil.rfcDateformatter.format(
                         ZonedDateTime.ofInstant(
                             ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
       } else {
@@ -159,4 +160,12 @@
       }
     }
   }
+
+  private String getChangeUrl(ChangeData changeData) {
+    return canonicalWebUrl.get()
+        + "c/"
+        + changeData.project().get()
+        + "/+/"
+        + changeData.getId().get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index f34fe33..9ff2c05 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -40,18 +40,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -68,18 +63,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -104,18 +94,14 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
         newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            null,
-            "Some Comment on File 1",
-            null);
+            getChangeUrl(changeInfo) + "/1", null, null, "Some Comment on File 1", null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -141,18 +127,13 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -171,19 +152,14 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
     assertThat(comments).hasSize(2);
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            null,
-            "Some Inline Comment",
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     // Set account state to inactive
@@ -206,17 +182,12 @@
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     assertThat(comments).hasSize(2);
     String ts =
-        MailUtil.rfcDateformatter.format(
+        MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
             .from(user.emailAddress)
@@ -238,12 +209,7 @@
 
     // Build Message
     String txt =
-        newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
             .from(user.emailAddress)
@@ -257,4 +223,8 @@
     assertThat(message.body()).contains("was unable to parse your email");
     assertThat(message.headers()).containsKey("Subject");
   }
+
+  private String getChangeUrl(ChangeInfo changeInfo) {
+    return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 4f51e1f..8d21b5b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.mail.EmailHeader;
 import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index ffff121..1ab0407 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -1097,7 +1097,7 @@
       ci.message = "comment with impersonation";
       ri.message = "message with impersonation";
       ri.label("Code-Review", 1);
-      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
+      adminRestSession.postWithHeader(prefix + "review", runAs, ri).assertOK();
 
       di.message = "draft with impersonation";
       adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated();
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
index a5d78c6..87c5ace 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -610,7 +610,7 @@
   }
 
   private void addListener(NotesMigrationStateListener listener) {
-    addedListeners.add(listeners.add(listener));
+    addedListeners.add(listeners.add("gerrit", listener));
   }
 
   private ImmutableSortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
deleted file mode 100644
index 834dbfa..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 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.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.reviewdb.client.Change;
-import java.io.File;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-@UseLocalDisk
-public class ReflogIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
-  @Test
-  public void guessRestApiInReflog() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
-      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
-      if (!log.exists()) {
-        log.getParentFile().mkdirs();
-        assertThat(log.createNewFile()).isTrue();
-      }
-
-      gApi.changes().id(id.get()).topic("foo");
-      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
-      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/BUILD b/javatests/com/google/gerrit/acceptance/server/project/BUILD
index efa1cdb..42dfbac 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/project/BUILD
@@ -4,4 +4,5 @@
     srcs = glob(["*IT.java"]),
     group = "server_project",
     labels = ["server"],
+    deps = ["//java/com/google/gerrit/mail"],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 0257240..8b0c56b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -43,8 +43,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -80,6 +82,7 @@
 
     eventListenerRegistration =
         source.add(
+            "gerrit",
             new CommentAddedListener() {
               @Override
               public void onCommentAdded(Event event) {
@@ -334,6 +337,14 @@
     gApi.changes().id(changeId).current().submit();
   }
 
+  @Test
+  public void customLabel_withBranch() throws Exception {
+    label.setRefPatterns(Arrays.asList("master"));
+    saveLabelConfig();
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    assertThat(cfg.getLabelSections().get(label.getName()).getRefPatterns()).contains("master");
+  }
+
   private void assertLabelStatus(String changeId, String testLabel) throws Exception {
     ChangeInfo changeInfo = getWithLabels(changeId);
     LabelInfo labelInfo = changeInfo.labels.get(testLabel);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ca0cae4..cfdd781 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import java.util.EnumSet;
 import java.util.List;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
new file mode 100644
index 0000000..8abb59d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.testing.Util;
+import java.io.File;
+import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+@UseLocalDisk
+public class ReflogIT extends AbstractDaemonTest {
+  @Test
+  public void guessRestApiInReflog() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
+    }
+  }
+
+  @Test
+  public void reflogUpdatedBySubmittingChange() throws Exception {
+    BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
+    List<ReflogEntryInfo> reflog = branchApi.reflog();
+    assertThat(reflog).isNotEmpty();
+
+    // Current number of entries in the reflog
+    int refLogLen = reflog.size();
+
+    // Create and submit a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revision = r.getCommit().name();
+    ReviewInput in = ReviewInput.approve();
+    gApi.changes().id(changeId).revision(revision).review(in);
+    gApi.changes().id(changeId).revision(revision).submit();
+
+    // Submitting the change causes a new entry in the reflog
+    reflog = branchApi.reflog();
+    assertThat(reflog).hasSize(refLogLen + 1);
+  }
+
+  @Test
+  public void regularUserIsNotAllowedToGetReflog() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void ownerUserIsAllowedToGetReflog() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("get-reflog"));
+    groupApi.addMembers("user");
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(), Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
+      u.save();
+    }
+
+    setApiUser(user);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void adminUserIsAllowedToGetReflog() throws Exception {
+    setApiUser(admin);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
index 2e96c0b..1f547f7 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
@@ -5,6 +5,6 @@
     group = "server_rules",
     labels = ["server"],
     deps = [
-        "@prolog_runtime//jar",
+        "@prolog-runtime//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
new file mode 100644
index 0000000..d1b05e3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2018 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.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+@NoHttpd
+public class IgnoreSelfApprovalRuleIT extends AbstractDaemonTest {
+  @Inject private IgnoreSelfApprovalRule rule;
+
+  @Test
+  public void blocksWhenUploaderIsOnlyApprover() throws Exception {
+    enableRule("Code-Review", true);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+
+    assertThat(submitRecords).hasSize(1);
+    SubmitRecord result = submitRecords.iterator().next();
+    assertThat(result.status).isEqualTo(SubmitRecord.Status.NOT_READY);
+    assertThat(result.labels).isNotEmpty();
+    assertThat(result.requirements)
+        .containsExactly(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+  }
+
+  @Test
+  public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
+    enableRule("Code-Review", true);
+
+    // Create change as user
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    // Approve as admin
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  @Test
+  public void doesNothingByDefault() throws Exception {
+    enableRule("Code-Review", false);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  private void enableRule(String labelName, boolean newState) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Map<String, LabelType> localLabelSections = u.getConfig().getLabelSections();
+      if (localLabelSections.isEmpty()) {
+        localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
+      }
+      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index a4d9acb..98dca3e 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -71,18 +71,17 @@
 
   @Test
   public void testUserPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
-    modifySubmitRules("commit_author(user(1000000), 'John Doe', 'john.doe@example.com')");
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    modifySubmitRules(
+        String.format(
+            "gerrit:commit_author(user(%d), '%s', '%s')",
+            user.getId().get(), user.fullName, user.email));
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   @Test
   public void testCommitAuthorPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
     modifySubmitRules("gerrit:commit_author(Id)");
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   private SubmitRecord.Status statusForRule() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
index f0b937c..9d6ae44 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 
 import com.google.common.collect.ImmutableList;
@@ -63,7 +62,7 @@
       command.append(" --message ").append(message);
     }
     String response = adminSshSession.exec(command.toString());
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
new file mode 100644
index 0000000..ed3cdbc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2018 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.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public abstract class AbstractIndexTests extends AbstractDaemonTest {
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
+  /** @param injector injector */
+  public abstract void configureIndex(Injector injector) throws Exception;
+
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void indexChange() throws Exception {
+    configureIndex(server.getTestInjector());
+
+    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+    String changeId = change.getChangeId();
+    String changeLegacyId = change.getChange().getId().toString();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    disableChangeIndexWrites();
+    amendChange(changeId, "second test", "test2.txt", "test2");
+
+    assertChangeQuery("message:second", change.getChange(), false);
+    enableChangeIndexWrites();
+
+    changeIndexedCounter.clear();
+    String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
+    adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+    assertChangeQuery("message:second", change.getChange(), true);
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void indexProject() throws Exception {
+    configureIndex(server.getTestInjector());
+
+    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+    String changeId = change.getChangeId();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    disableChangeIndexWrites();
+    amendChange(changeId, "second test", "test2.txt", "test2");
+
+    assertChangeQuery("message:second", change.getChange(), false);
+    enableChangeIndexWrites();
+
+    changeIndexedCounter.clear();
+    String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
+    adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    boolean indexing = true;
+    while (indexing) {
+      String out = adminSshSession.exec("gerrit show-queue --wide");
+      adminSshSession.assertSuccess();
+      indexing = out.contains("Index all changes of project " + project.get());
+    }
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+    assertChangeQuery("message:second", change.getChange(), true);
+  }
+
+  protected void assertChangeQuery(String q, ChangeData change, boolean assertTrue)
+      throws Exception {
+    List<Integer> ids = query(q).stream().map(c -> c._number).collect(toList());
+    if (assertTrue) {
+      assertThat(ids).contains(change.getId().get());
+    } else {
+      assertThat(ids).doesNotContain(change.getId().get());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index 87b2920..a01cd3e 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -1,9 +1,40 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["AbstractIndexTests.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
+
 acceptance_tests(
-    srcs = glob(["*IT.java"]),
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["ElasticIndexIT.java"],
+    ),
     group = "ssh",
     labels = ["ssh"],
     vm_args = ["-Xmx512m"],
-    deps = ["//lib/commons:compress"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/logging",
+        "//lib/commons:compress",
+    ],
+)
+
+acceptance_tests(
+    srcs = ["ElasticIndexIT.java"],
+    group = "elastic",
+    labels = [
+        "docker",
+        "elastic",
+        "exclusive",
+        "ssh",
+    ],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/elasticsearch",
+        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
+        "//lib/commons:compress",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
index 1c39181..2b00718 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
@@ -36,13 +35,13 @@
     RevCommit c = commitBuilder().add("a.txt", "some content").create();
 
     String response = adminSshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
 
     RemoteRefUpdate u =
         pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index f2dda42..e583179 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
@@ -33,7 +32,7 @@
     String newProjectName = "newProject";
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNotNull();
   }
@@ -46,7 +45,7 @@
     String newProjectName = "newProject";
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isTrue();
+    adminSshSession.assertFailure();
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
new file mode 100644
index 0000000..9d69955
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 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.ssh;
+
+import com.google.gerrit.elasticsearch.ElasticContainer;
+import com.google.gerrit.elasticsearch.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+
+public class ElasticIndexIT extends AbstractIndexTests {
+
+  private static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
+  @ConfigSuite.Default
+  public static Config elasticsearchV2() {
+    return getConfig(ElasticVersion.V2_4);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV5() {
+    return getConfig(ElasticVersion.V5_6);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
+  }
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index f2a388e..4384ab5 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
@@ -56,7 +55,7 @@
   public void testGc() throws Exception {
     String response =
         adminSshSession.exec("gerrit gc \"" + project.get() + "\" \"" + project2.get() + "\"");
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(project, project2);
     gcAssert.assertHasNoPackFile(allProjects, project3);
@@ -66,7 +65,7 @@
   @UseLocalDisk
   public void testGcAll() throws Exception {
     String response = adminSshSession.exec("gerrit gc --all");
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(allProjects, project, project2, project3);
   }
@@ -74,7 +73,7 @@
   @Test
   public void gcWithoutCapability_Error() throws Exception {
     userSshSession.exec("gerrit gc --all");
-    assertThat(userSshSession.hasError()).isTrue();
+    userSshSession.assertFailure();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
     assertError("maintain server not permitted", error);
diff --git a/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
similarity index 67%
copy from java/com/google/gerrit/extensions/common/SetDashboardInput.java
copy to javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
index 13d2b9d..196a1e5 100644
--- a/java/com/google/gerrit/extensions/common/SetDashboardInput.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/IndexIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.common;
+package com.google.gerrit.acceptance.ssh;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.inject.Injector;
 
-public class SetDashboardInput {
-  @DefaultInput public String id;
-  public String commitMessage;
+public class IndexIT extends AbstractIndexTests {
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {}
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index f741f36..50b2a78d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -149,8 +148,7 @@
   public void shouldFailWithFilesWithoutPatchSetsOrCurrentPatchSetsOption() throws Exception {
     String changeId = createChange().getChangeId();
     adminSshSession.exec("gerrit query --files " + changeId);
-    assertThat(adminSshSession.hasError()).isTrue();
-    assertThat(adminSshSession.getError()).contains("needs --patch-sets or --current-patch-set");
+    adminSshSession.assertFailure("needs --patch-sets or --current-patch-set");
   }
 
   @Test
@@ -305,7 +303,7 @@
   private List<ChangeAttribute> executeSuccessfulQuery(String params, SshSession session)
       throws Exception {
     String rawResponse = session.exec("gerrit query --format=JSON " + params);
-    assertWithMessage(session.getError()).that(session.hasError()).isFalse();
+    session.assertSuccess();
     return getChanges(rawResponse);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 596fc87..237859c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -52,7 +51,7 @@
   private void setReviewer(boolean add, String id) throws Exception {
     adminSshSession.exec(
         String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email, id));
-    assert_().withMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     ImmutableSet<Id> reviewers = change.getChange().getReviewers().all();
     if (add) {
       assertThat(reviewers).contains(user.id);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5694bd0..e603413 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.acceptance.ssh;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -28,7 +30,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.sshd.Commands;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
@@ -39,7 +40,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // TODO: It would be better to dynamically generate these lists
-  private static final List<String> COMMON_ROOT_COMMANDS =
+  private static final ImmutableList<String> COMMON_ROOT_COMMANDS =
       ImmutableList.of(
           "apropos",
           "close-connection",
@@ -57,7 +58,7 @@
           "show-queue",
           "version");
 
-  private static final List<String> MASTER_ONLY_ROOT_COMMANDS =
+  private static final ImmutableList<String> MASTER_ONLY_ROOT_COMMANDS =
       ImmutableList.of(
           "ban-commit",
           "create-account",
@@ -79,21 +80,15 @@
           "stream-events",
           "test-submit");
 
-  private static final Map<String, List<String>> MASTER_COMMANDS =
+  private static final ImmutableMap<String, List<String>> MASTER_COMMANDS =
       ImmutableMap.of(
           Commands.ROOT,
-          ImmutableList.copyOf(
-              new ArrayList<String>() {
-                private static final long serialVersionUID = 1L;
-
-                {
-                  addAll(COMMON_ROOT_COMMANDS);
-                  addAll(MASTER_ONLY_ROOT_COMMANDS);
-                  Collections.sort(this);
-                }
-              }),
+          Streams.concat(COMMON_ROOT_COMMANDS.stream(), MASTER_ONLY_ROOT_COMMANDS.stream())
+              .sorted()
+              .collect(toImmutableList()),
           "index",
-          ImmutableList.of("changes", "project"), // "activate" and "start" are not included
+          ImmutableList.of(
+              "changes", "changes-in-project"), // "activate" and "start" are not included
           "logging",
           ImmutableList.of("ls", "set"),
           "plugin",
@@ -101,7 +96,7 @@
           "test-submit",
           ImmutableList.of("rule", "type"));
 
-  private static final Map<String, List<String>> SLAVE_COMMANDS =
+  private static final ImmutableMap<String, List<String>> SLAVE_COMMANDS =
       ImmutableMap.of(
           Commands.ROOT,
           COMMON_ROOT_COMMANDS,
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
new file mode 100644
index 0000000..899b0cf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -0,0 +1,92 @@
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+public class SshTraceIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+  }
+
+  @Test
+  public void sshCallWithoutTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project new1");
+    adminSshSession.assertSuccess();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.foundTraceId).isFalse();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void sshCallWithTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace new2");
+
+    // The trace ID is written to stderr.
+    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void sshCallWithTraceAndProvidedTraceId() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace --trace-id issue/123 new3");
+
+    // The trace ID is written to stderr.
+    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void sshCallWithTraceIdAndWithoutTraceFails() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace-id issue/123 new3");
+    adminSshSession.assertFailure("A trace ID can only be set if --trace was specified.");
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    String traceId;
+    Boolean foundTraceId;
+    Boolean isLoggingForced;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.foundTraceId = traceId != null;
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/tests.bzl b/javatests/com/google/gerrit/acceptance/tests.bzl
index 4b3b802d..08556a0 100644
--- a/javatests/com/google/gerrit/acceptance/tests.bzl
+++ b/javatests/com/google/gerrit/acceptance/tests.bzl
@@ -1,21 +1,21 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 def acceptance_tests(
-    group,
-    deps = [],
-    labels = [],
-    vm_args = ['-Xmx256m'],
-    **kwargs):
-  junit_tests(
-    name = group,
-    deps = deps + [
-      '//java/com/google/gerrit/acceptance:lib',
-    ],
-    tags = labels + [
-      'acceptance',
-      'slow',
-    ],
-    size = "large",
-    jvm_flags = vm_args,
-    **kwargs
-  )
+        group,
+        deps = [],
+        labels = [],
+        vm_args = ["-Xmx256m"],
+        **kwargs):
+    junit_tests(
+        name = group,
+        deps = deps + [
+            "//java/com/google/gerrit/acceptance:lib",
+        ],
+        tags = labels + [
+            "acceptance",
+            "slow",
+        ],
+        size = "large",
+        jvm_flags = vm_args,
+        **kwargs
+    )
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
new file mode 100644
index 0000000..954b0e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -0,0 +1,656 @@
+// Copyright (C) 2018 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.testsuite.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Objects;
+import java.util.Optional;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class GroupOperationsImplTest extends AbstractDaemonTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Inject private AccountOperations accountOperations;
+
+  @Inject private GroupOperationsImpl groupOperations;
+
+  private int uniqueGroupNameIndex;
+
+  @Test
+  public void groupCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isNotEmpty();
+  }
+
+  @Test
+  public void twoGroupsWithoutAnyParametersDoNotClash() throws Exception {
+    AccountGroup.UUID groupUuid1 = groupOperations.newGroup().create();
+    AccountGroup.UUID groupUuid2 = groupOperations.newGroup().create();
+
+    TestGroup group1 = groupOperations.group(groupUuid1).get();
+    TestGroup group2 = groupOperations.group(groupUuid2).get();
+    assertThat(group1.groupUuid()).isNotEqualTo(group2.groupUuid());
+  }
+
+  @Test
+  public void groupCreatedByTestApiCanBeRetrievedViaOfficialApi() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("unique group created via test API").create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isEqualTo("unique group created via test API");
+  }
+
+  @Test
+  public void specifiedNameIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("XYZ-123-this-name-must-be-unique").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.name).isEqualTo("XYZ-123-this-name-must-be-unique");
+  }
+
+  @Test
+  public void specifiedDescriptionIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("All authenticated users").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isEqualTo("All authenticated users");
+  }
+
+  @Test
+  public void requestingNoDescriptionIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isNull();
+  }
+
+  @Test
+  public void specifiedOwnerIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID ownerGroupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(ownerGroupUuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.ownerId).isEqualTo(ownerGroupUuid.get());
+  }
+
+  @Test
+  public void specifiedVisibilityIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    GroupInfo foundGroup1 = getGroupFromServer(group1Uuid);
+    GroupInfo foundGroup2 = getGroupFromServer(group2Uuid);
+    assertThat(foundGroup1.options.visibleToAll).isTrue();
+    // False == null
+    assertThat(foundGroup2.options.visibleToAll).isNull();
+  }
+
+  @Test
+  public void specifiedMembersAreRespectedForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+    Account.Id account3Id = accountOperations.newAccount().create();
+    Account.Id account4Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .members(account1Id, account2Id)
+            .addMember(account3Id)
+            .addMember(account4Id)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id, account3Id, account4Id);
+  }
+
+  @Test
+  public void directlyAddingMembersIsPossibleForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addMember(account1Id).addMember(account2Id).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id);
+  }
+
+  @Test
+  public void requestingNoMembersIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members).isEmpty();
+  }
+
+  @Test
+  public void specifiedSubgroupsAreRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group3Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group4Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .subgroups(group1Uuid, group2Uuid)
+            .addSubgroup(group3Uuid)
+            .addSubgroup(group4Uuid)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid, group3Uuid, group4Uuid);
+  }
+
+  @Test
+  public void directlyAddingSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addSubgroup(group1Uuid).addSubgroup(group2Uuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid);
+  }
+
+  @Test
+  public void requestingNoSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes).isEmpty();
+  }
+
+  @Test
+  public void existingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID groupUuid = createGroupInServer(createArbitraryGroupInput());
+
+    boolean exists = groupOperations.group(groupUuid).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    boolean exists = groupOperations.group(notExistingGroupUuid).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingGroupFails() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    expectedException.expect(IllegalStateException.class);
+    groupOperations.group(notExistingGroupUuid).get();
+  }
+
+  @Test
+  public void groupNotCreatedByTestApiCanBeRetrieved() throws Exception {
+    GroupInput input = createArbitraryGroupInput();
+    input.name = "unique group not created via test API";
+    AccountGroup.UUID groupUuid = createGroupInServer(input);
+
+    TestGroup foundGroup = groupOperations.group(groupUuid).get();
+
+    assertThat(foundGroup.groupUuid()).isEqualTo(groupUuid);
+    assertThat(foundGroup.name()).isEqualTo("unique group not created via test API");
+  }
+
+  @Test
+  public void uuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID foundGroupUuid = groupOperations.group(groupUuid).get().groupUuid();
+
+    assertThat(foundGroupUuid).isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void nameOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    String groupName = groupOperations.group(groupUuid).get().name();
+
+    assertThat(groupName).isEqualTo("ABC-789-this-name-must-be-unique");
+  }
+
+  @Test
+  public void nameKeyOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    AccountGroup.NameKey groupName = groupOperations.group(groupUuid).get().nameKey();
+
+    assertThat(groupName).isEqualTo(new AccountGroup.NameKey("ABC-789-this-name-must-be-unique"));
+  }
+
+  @Test
+  public void descriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .description("This is a very detailed description of this group.")
+            .create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).hasValue("This is a very detailed description of this group.");
+  }
+
+  @Test
+  public void emptyDescriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("owner group");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID ownerGroupUuid = groupOperations.group(groupUuid).get().ownerGroupUuid();
+
+    assertThat(ownerGroupUuid).isEqualTo(originalOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID visibleGroupUuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID invisibleGroupUuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    TestGroup visibleGroup = groupOperations.group(visibleGroupUuid).get();
+    TestGroup invisibleGroup = groupOperations.group(invisibleGroupUuid).get();
+
+    assertThat(visibleGroup.visibleToAll()).named("visibility of visible group").isTrue();
+    assertThat(invisibleGroup.visibleToAll()).named("visibility of invisible group").isFalse();
+  }
+
+  @Test
+  public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
+    GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group.id);
+
+    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+
+    assertThat(createdOn).isEqualTo(group.createdOn);
+  }
+
+  @Test
+  public void membersOfExistingGroupCanBeRetrieved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId3 = new Account.Id(3000);
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().members(memberId1, memberId2, memberId3).create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).containsExactly(memberId1, memberId2, memberId3);
+  }
+
+  @Test
+  public void emptyMembersOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void subgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2, subgroupUuid3).create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void emptySubgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void updateWithoutAnyParametersIsANoop() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+    TestGroup originalGroup = groupOperations.group(groupUuid).get();
+
+    groupOperations.group(groupUuid).forUpdate().update();
+
+    TestGroup updatedGroup = groupOperations.group(groupUuid).get();
+    assertThat(updatedGroup).isEqualTo(originalGroup);
+  }
+
+  @Test
+  public void updateWritesToInternalGroupSystem() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    String currentDescription = getGroupFromServer(groupUuid).description;
+    assertThat(currentDescription).isEqualTo("updated description");
+  }
+
+  @Test
+  public void nameCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().name("original name").create();
+
+    groupOperations.group(groupUuid).forUpdate().name("updated name").update();
+
+    String currentName = groupOperations.group(groupUuid).get().name();
+    assertThat(currentName).isEqualTo("updated name");
+  }
+
+  @Test
+  public void descriptionCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).hasValue("updated description");
+  }
+
+  @Test
+  public void descriptionCanBeCleared() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().clearDescription().update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidCanBeUpdated() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("original owner");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID updatedOwnerGroupUuid = new AccountGroup.UUID("updated owner");
+    groupOperations.group(groupUuid).forUpdate().ownerGroupUuid(updatedOwnerGroupUuid).update();
+
+    AccountGroup.UUID currentOwnerGroupUuid =
+        groupOperations.group(groupUuid).get().ownerGroupUuid();
+    assertThat(currentOwnerGroupUuid).isEqualTo(updatedOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().visibleToAll(true).create();
+
+    groupOperations.group(groupUuid).forUpdate().visibleToAll(false).update();
+
+    boolean visibleToAll = groupOperations.group(groupUuid).get().visibleToAll();
+    assertThat(visibleToAll).isFalse();
+  }
+
+  @Test
+  public void membersCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    groupOperations.group(groupUuid).forUpdate().addMember(memberId1).addMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1, memberId2);
+  }
+
+  @Test
+  public void membersCanBeRemoved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1);
+  }
+
+  @Test
+  public void memberAdditionAndRemovalCanBeMixed() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeMember(memberId1)
+        .addMember(memberId3)
+        .update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId2, memberId3);
+  }
+
+  @Test
+  public void membersCanBeCleared() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearMembers().update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void furtherMembersCanBeAddedAfterClearingAll() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations.group(groupUuid).forUpdate().clearMembers().addMember(memberId3).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId3);
+  }
+
+  @Test
+  public void subgroupsCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .addSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid2)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2);
+  }
+
+  @Test
+  public void subgroupsCanBeRemoved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeSubgroup(subgroupUuid2).update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1);
+  }
+
+  @Test
+  public void subgroupAdditionAndRemovalCanBeMixed() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void subgroupsCanBeCleared() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearSubgroups().update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void furtherSubgroupsCanBeAddedAfterClearingAll() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .clearSubgroups()
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid3);
+  }
+
+  private GroupInput createArbitraryGroupInput() {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("verifiers-" + uniqueGroupNameIndex++);
+    return groupInput;
+  }
+
+  private GroupInfo getGroupFromServer(AccountGroup.UUID groupUuid) throws RestApiException {
+    return gApi.groups().id(groupUuid.get()).detail();
+  }
+
+  private AccountGroup.UUID createGroupInServer(GroupInput input) throws RestApiException {
+    GroupInfo group = gApi.groups().create(input).detail();
+    return new AccountGroup.UUID(group.id);
+  }
+
+  private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
+    return new Correspondence<AccountInfo, Account.Id>() {
+      @Override
+      public boolean compare(AccountInfo actualAccount, Account.Id expectedId) {
+        Account.Id accountId =
+            Optional.ofNullable(actualAccount)
+                .map(account -> account._accountId)
+                .map(Account.Id::new)
+                .orElse(null);
+        return Objects.equals(accountId, expectedId);
+      }
+
+      @Override
+      public String toString() {
+        return "has ID";
+      }
+    };
+  }
+
+  private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
+    return new Correspondence<GroupInfo, AccountGroup.UUID>() {
+      @Override
+      public boolean compare(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
+        AccountGroup.UUID groupUuid =
+            Optional.ofNullable(actualGroup)
+                .map(group -> group.id)
+                .map(AccountGroup.UUID::new)
+                .orElse(null);
+        return Objects.equals(groupUuid, expectedUuid);
+      }
+
+      @Override
+      public String toString() {
+        return "has UUID";
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index ba9a5bc..c4f2c0a 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -1,32 +1,14 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-SERVER_TEST_SRCS = [
-    "AutoValueTest.java",
-    "VersionTest.java",
-]
-
 junit_tests(
-    name = "client_tests",
-    srcs = glob(
-        ["**/*.java"],
-        exclude = SERVER_TEST_SRCS,
-    ),
-    deps = [
-        "//java/com/google/gerrit/common:client",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib/truth",
-    ],
-)
-
-junit_tests(
-    name = "server_tests",
-    srcs = SERVER_TEST_SRCS,
+    name = "common_tests",
+    srcs = glob(["**/*.java"]),
     tags = ["no_windows"],
     deps = [
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common:version",
         "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
new file mode 100644
index 0000000..a59c015
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class AccessSectionTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = new AccessSection(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    assertThat(accessSection.getPermissions()).isNotNull();
+    assertThat(accessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
+
+    exception.expect(NullPointerException.class);
+    accessSection.setPermissions(null);
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(new Permission(Permission.ABANDON), new Permission(Permission.ABANDON)));
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission abandonPermissionLowerCase =
+        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission abandonPermissionUpperCase =
+        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
+
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase));
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.getPermission("non-existing")).isNull();
+    assertThat(accessSection.getPermission("non-existing", false)).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+
+    exception.expect(NullPointerException.class);
+    accessSection.getPermission(null);
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
+        .isEqualTo(new Permission(Permission.SUBMIT));
+
+    exception.expect(NullPointerException.class);
+    accessSection.getPermission(null, true);
+  }
+
+  @Test
+  public void addPermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+
+    exception.expect(NullPointerException.class);
+    accessSection.addPermission(null);
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(abandonPermission);
+    permissions.add(rebasePermission);
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    permissions.add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(new Permission(Permission.ABANDON));
+    permissions.add(new Permission(Permission.REBASE));
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void removePermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    exception.expect(NullPointerException.class);
+    accessSection.remove(null);
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    exception.expect(NullPointerException.class);
+    accessSection.removePermission(null);
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void mergeAccessSections() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
+    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
+    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
+
+    accessSection1.mergeFrom(accessSection2);
+    assertThat(accessSection1.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+
+    exception.expect(NullPointerException.class);
+    accessSection.mergeFrom(null);
+  }
+
+  @Test
+  public void testEquals() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
+
+    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
+    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
+    assertThat(accessSection.equals(accessSectionOther)).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(accessSection.equals(accessSectionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index 4c4c769..dcd3c05 100644
--- a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -14,20 +14,20 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
 
 import org.junit.Test;
 
 public class EncodePathSeparatorTest {
   @Test
   public void defaultBehaviour() {
-    assertEquals("a/b", new GitwebType().replacePathSeparator("a/b"));
+    assertThat(new GitwebType().replacePathSeparator("a/b")).isEqualTo("a/b");
   }
 
   @Test
   public void exclamationMark() {
     GitwebType gitwebType = new GitwebType();
     gitwebType.setPathSeparator('!');
-    assertEquals("a!b", gitwebType.replacePathSeparator("a/b"));
+    assertThat(gitwebType.replacePathSeparator("a/b")).isEqualTo("a!b");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
new file mode 100644
index 0000000..fdc9dc6
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class GroupReferenceTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void forGroupDescription() {
+    String name = "foo";
+    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    GroupReference groupReference =
+        GroupReference.forGroup(
+            new GroupDescription.Basic() {
+
+              @Override
+              public String getUrl() {
+                return null;
+              }
+
+              @Override
+              public String getName() {
+                return name;
+              }
+
+              @Override
+              public UUID getGroupUUID() {
+                return uuid;
+              }
+
+              @Override
+              public String getEmailAddress() {
+                return null;
+              }
+            });
+    assertThat(groupReference.getName()).isEqualTo(name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+  }
+
+  @Test
+  public void create() {
+    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+    assertThat(groupReference.getName()).isEqualTo(name);
+  }
+
+  @Test
+  public void createWithoutUuid() {
+    // GroupReferences where the UUID is null are used to represent groups from project.config that
+    // cannot be resolved.
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(null, name);
+    assertThat(groupReference.getUUID()).isNull();
+    assertThat(groupReference.getName()).isEqualTo(name);
+  }
+
+  @Test
+  public void cannotCreateWithoutName() {
+    exception.expect(NullPointerException.class);
+    new GroupReference(new AccountGroup.UUID("uuid"), null);
+  }
+
+  @Test
+  public void isGroupReference() {
+    assertThat(GroupReference.isGroupReference("foo")).isFalse();
+    assertThat(GroupReference.isGroupReference("groupfoo")).isFalse();
+    assertThat(GroupReference.isGroupReference("group foo")).isTrue();
+    assertThat(GroupReference.isGroupReference("group foo-bar")).isTrue();
+    assertThat(GroupReference.isGroupReference("group foo bar")).isTrue();
+  }
+
+  @Test
+  public void extractGroupName() {
+    assertThat(GroupReference.extractGroupName("foo")).isNull();
+    assertThat(GroupReference.extractGroupName("groupfoo")).isNull();
+    assertThat(GroupReference.extractGroupName("group foo")).isEqualTo("foo");
+    assertThat(GroupReference.extractGroupName("group foo-bar")).isEqualTo("foo-bar");
+    assertThat(GroupReference.extractGroupName("group foo bar")).isEqualTo("foo bar");
+  }
+
+  @Test
+  public void getAndSetUuid() {
+    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid);
+
+    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    groupReference.setUUID(uuid2);
+    assertThat(groupReference.getUUID()).isEqualTo(uuid2);
+
+    // GroupReferences where the UUID is null are used to represent groups from project.config that
+    // cannot be resolved.
+    groupReference.setUUID(null);
+    assertThat(groupReference.getUUID()).isNull();
+  }
+
+  @Test
+  public void getAndSetName() {
+    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(uuid, name);
+    assertThat(groupReference.getName()).isEqualTo(name);
+
+    String name2 = "bar";
+    groupReference.setName(name2);
+    assertThat(groupReference.getName()).isEqualTo(name2);
+
+    exception.expect(NullPointerException.class);
+    groupReference.setName(null);
+  }
+
+  @Test
+  public void toConfigValue() {
+    String name = "foo";
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-foo"), name);
+    assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
+  }
+
+  @Test
+  public void testEquals() {
+    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    String name1 = "foo";
+    String name2 = "bar";
+
+    GroupReference groupReference1 = new GroupReference(uuid1, name1);
+    GroupReference groupReference2 = new GroupReference(uuid1, name2);
+    GroupReference groupReference3 = new GroupReference(uuid2, name1);
+
+    assertThat(groupReference1.equals(groupReference2)).isTrue();
+    assertThat(groupReference1.equals(groupReference3)).isFalse();
+    assertThat(groupReference2.equals(groupReference3)).isFalse();
+  }
+
+  @Test
+  public void testHashcode() {
+    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid1");
+    assertThat(new GroupReference(uuid1, "foo").hashCode())
+        .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
+
+    // Check that the following calls don't fail with an exception.
+    new GroupReference(null, "bar").hashCode();
+    new GroupReference(new AccountGroup.UUID(null), "bar").hashCode();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
new file mode 100644
index 0000000..6c3befb
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+  @Test
+  public void sortLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v1 = new LabelValue((short) 1, "One");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v0, v1));
+    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+  }
+
+  @Test
+  public void insertMissingLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelValue v5 = new LabelValue((short) 5, "Five");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v5, v0));
+    assertThat(types.getValues())
+        .containsExactly(
+            v0,
+            new LabelValue((short) 1, ""),
+            v2,
+            new LabelValue((short) 3, ""),
+            new LabelValue((short) 4, ""),
+            v5)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index 0f067c4..b646d2b 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
@@ -26,155 +24,148 @@
 public class ParameterizedStringTest {
   @Test
   public void emptyString() {
-    final ParameterizedString p = new ParameterizedString("");
-    assertEquals("", p.getPattern());
-    assertEquals("", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = new ParameterizedString("");
+    assertThat(p.getPattern()).isEmpty();
+    assertThat(p.getRawPattern()).isEmpty();
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEmpty();
   }
 
   @Test
   public void asis1() {
-    final ParameterizedString p = ParameterizedString.asis("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("${bar}c", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = ParameterizedString.asis("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("${bar}c");
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("${bar}c", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("${bar}c");
   }
 
   @Test
   public void replace1() {
-    final ParameterizedString p = new ParameterizedString("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("frobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("frobinatorc");
   }
 
   @Test
   public void replace2() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinatorc");
   }
 
   @Test
   public void replace3() {
-    final ParameterizedString p = new ParameterizedString("a${bar}");
-    assertEquals("a${bar}", p.getPattern());
-    assertEquals("a{0}", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}");
+    assertThat(p.getPattern()).isEqualTo("a${bar}");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinator", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinator");
   }
 
   @Test
   public void replace4() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("", p.bind(a)[0]);
-    assertEquals("ac", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("ac");
   }
 
   @Test
   public void replaceToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
@@ -182,226 +173,216 @@
     ParameterizedString p =
         new ParameterizedString(
             "hi, ${userName.toUpperCase},your eamil address is '${email.toLowerCase.localPart}'.right?");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("userName"));
-    assertTrue(p.getParameterNames().contains("email"));
+    assertThat(p.getParameterNames()).containsExactly("userName", "email");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("userName", "firstName lastName");
     a.put("email", "FIRSTNAME.LASTNAME@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(2, p.bind(a).length);
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(2);
 
-    assertEquals("FIRSTNAME LASTNAME", p.bind(a)[0]);
-    assertEquals("firstname.lastname", p.bind(a)[1]);
-    assertEquals(
-        "hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?", p.replace(a));
+    assertThat(p.bind(a)[0]).isEqualTo("FIRSTNAME LASTNAME");
+    assertThat(p.bind(a)[1]).isEqualTo("firstname.lastname");
+    assertThat(p.replace(a))
+        .isEqualTo("hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?");
   }
 
   @Test
   public void replaceToUpperCaseToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceToUpperCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceToUpperCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceLocalNameToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalNameToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceLocalNameAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
+    assertThat(p.getParameterNames()).hasSize(1);
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceToLowerCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceSubmitTooltipWithVariables() {
     ParameterizedString p = new ParameterizedString("Submit patch set ${patchSet} into ${branch}");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("patchSet"));
+    assertThat(p.getParameterNames()).hasSize(2);
+    assertThat(p.getParameterNames()).containsExactly("patchSet", "branch");
 
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertNotNull(p.bind(params));
-    assertEquals(2, p.bind(params).length);
-    assertEquals("42", p.bind(params)[0]);
-    assertEquals("foo", p.bind(params)[1]);
-    assertEquals("Submit patch set 42 into foo", p.replace(params));
+    assertThat(p.bind(params)).isNotNull();
+    assertThat(p.bind(params)).hasLength(2);
+    assertThat(p.bind(params)[0]).isEqualTo("42");
+    assertThat(p.bind(params)[1]).isEqualTo("foo");
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 42 into foo");
   }
 
   @Test
@@ -411,7 +392,7 @@
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertEquals(0, p.bind(params).length);
-    assertEquals("Submit patch set 40 into master", p.replace(params));
+    assertThat(p.bind(params)).isEmpty();
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 40 into master");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
new file mode 100644
index 0000000..14c47b4
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
@@ -0,0 +1,399 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class PermissionRuleTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  private GroupReference groupReference;
+  private PermissionRule permissionRule;
+
+  @Before
+  public void setup() {
+    this.groupReference = new GroupReference(new AccountGroup.UUID("uuid"), "group");
+    this.permissionRule = new PermissionRule(groupReference);
+  }
+
+  @Test
+  public void getAndSetAction() {
+    assertThat(permissionRule.getAction()).isEqualTo(Action.ALLOW);
+
+    permissionRule.setAction(Action.DENY);
+    assertThat(permissionRule.getAction()).isEqualTo(Action.DENY);
+  }
+
+  @Test
+  public void cannotSetActionToNull() {
+    exception.expect(NullPointerException.class);
+    permissionRule.setAction(null);
+  }
+
+  @Test
+  public void setDeny() {
+    assertThat(permissionRule.isDeny()).isFalse();
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.isDeny()).isTrue();
+  }
+
+  @Test
+  public void setBlock() {
+    assertThat(permissionRule.isBlock()).isFalse();
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.isBlock()).isTrue();
+  }
+
+  @Test
+  public void setForce() {
+    assertThat(permissionRule.getForce()).isFalse();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.getForce()).isTrue();
+
+    permissionRule.setForce(false);
+    assertThat(permissionRule.getForce()).isFalse();
+  }
+
+  @Test
+  public void setMin() {
+    assertThat(permissionRule.getMin()).isEqualTo(0);
+
+    permissionRule.setMin(-2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+
+    permissionRule.setMin(2);
+    assertThat(permissionRule.getMin()).isEqualTo(2);
+  }
+
+  @Test
+  public void setMax() {
+    assertThat(permissionRule.getMax()).isEqualTo(0);
+
+    permissionRule.setMax(2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setMax(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(-2);
+  }
+
+  @Test
+  public void setRange() {
+    assertThat(permissionRule.getMin()).isEqualTo(0);
+    assertThat(permissionRule.getMax()).isEqualTo(0);
+
+    permissionRule.setRange(-2, 2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setRange(2, -2);
+    assertThat(permissionRule.getMin()).isEqualTo(-2);
+    assertThat(permissionRule.getMax()).isEqualTo(2);
+
+    permissionRule.setRange(1, 1);
+    assertThat(permissionRule.getMin()).isEqualTo(1);
+    assertThat(permissionRule.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(permissionRule.hasRange()).isFalse();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.hasRange()).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.hasRange()).isTrue();
+  }
+
+  @Test
+  public void getGroup() {
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
+  }
+
+  @Test
+  public void setGroup() {
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    assertThat(groupReference2).isNotEqualTo(groupReference);
+
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
+
+    permissionRule.setGroup(groupReference2);
+    assertThat(permissionRule.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void mergeFromAnyBlock() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isFalse();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2.setBlock();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isTrue();
+
+    permissionRule2.setDeny();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyDeny() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isFalse();
+    assertThat(permissionRule2.isDeny()).isFalse();
+
+    permissionRule2.setDeny();
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isTrue();
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyBatch() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+
+    permissionRule2.setAction(Action.BATCH);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
+
+    permissionRule2.setAction(Action.ALLOW);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+  }
+
+  @Test
+  public void mergeFromAnyForce() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isFalse();
+    assertThat(permissionRule2.getForce()).isFalse();
+
+    permissionRule2.setForce(true);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isTrue();
+
+    permissionRule2.setForce(false);
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isFalse();
+  }
+
+  @Test
+  public void mergeFromMergeRange() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+    permissionRule1.setRange(-1, 2);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+    permissionRule2.setRange(-2, 1);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getMin()).isEqualTo(-2);
+    assertThat(permissionRule1.getMax()).isEqualTo(2);
+    assertThat(permissionRule2.getMin()).isEqualTo(-2);
+    assertThat(permissionRule2.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void mergeFromGroupNotChanged() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
+
+    permissionRule1.mergeFrom(permissionRule2);
+    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
+    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void asString() {
+    assertThat(permissionRule.asString(true)).isEqualTo("group " + groupReference.getName());
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.asString(true)).isEqualTo("deny group " + groupReference.getName());
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.asString(true)).isEqualTo("block group " + groupReference.getName());
+
+    permissionRule.setAction(Action.BATCH);
+    assertThat(permissionRule.asString(true)).isEqualTo("batch group " + groupReference.getName());
+
+    permissionRule.setAction(Action.INTERACTIVE);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("interactive group " + groupReference.getName());
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("interactive +force group " + groupReference.getName());
+
+    permissionRule.setAction(Action.ALLOW);
+    assertThat(permissionRule.asString(true)).isEqualTo("+force group " + groupReference.getName());
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("+force +0..+1 group " + groupReference.getName());
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.asString(true))
+        .isEqualTo("+force -1..+1 group " + groupReference.getName());
+
+    assertThat(permissionRule.asString(false))
+        .isEqualTo("+force group " + groupReference.getName());
+  }
+
+  @Test
+  public void fromString() {
+    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("deny group A", true);
+    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("block group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("batch group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive +force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
+
+    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
+
+    permissionRule = PermissionRule.fromString("+force group A", false);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+  }
+
+  @Test
+  public void parseInt() {
+    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
+  }
+
+  @Test
+  public void testEquals() {
+    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setGroup(groupReference);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setDeny();
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setForce(true);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMin(-1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMax(1);
+    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
+  }
+
+  private void assertPermissionRule(
+      PermissionRule permissionRule,
+      String expectedGroupName,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
+    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
+    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
+    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
+    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
new file mode 100644
index 0000000..f76323f
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -0,0 +1,341 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission permission;
+
+  @Before
+  public void setup() {
+    this.permission = new Permission(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission("Code-Review").getLabel()).isNull();
+    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = new Permission(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRules()).isNotNull();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+    permission.setRules(ImmutableList.of(permissionRule3));
+    assertThat(permission.getRules()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(permissionRule1);
+    rules.add(permissionRule2);
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    rules.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2")));
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3")));
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference1)).isNull();
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+    assertThat(permission.getRule(groupReference, false)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule = new PermissionRule(groupReference);
+    permission.setRules(ImmutableList.of(permissionRule));
+    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
+  }
+
+  @Test
+  public void createMissingRuleOnGet() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+
+    assertThat(permission.getRule(groupReference, true))
+        .isEqualTo(new PermissionRule(groupReference));
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
+    assertThat(permission.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void mergePermissions() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+
+    Permission permission1 = new Permission("foo");
+    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permission2 = new Permission("bar");
+    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
+
+    permission1.mergeFrom(permission2);
+    assertThat(permission1.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permissionSameRulesOtherName = new Permission("bar");
+    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
+        ImmutableList.of(permissionRule1, permissionRule2));
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission permissionOther = new Permission(PERMISSION_NAME);
+    permissionOther.setRules(ImmutableList.of(permissionRule1));
+    assertThat(permission.equals(permissionOther)).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.equals(permissionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index 1249909..cc849eb 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -10,49 +10,85 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//lib:gson",
         "//lib:guava",
         "//lib:junit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/testcontainers",
-        "//lib/truth",
     ],
 )
 
-ELASTICSEARCH_TESTS = {i: "ElasticQuery" + i.capitalize() + "sTest.java" for i in [
+ELASTICSEARCH_DEPS = [
+    ":elasticsearch_test_utils",
+    "//java/com/google/gerrit/elasticsearch",
+    "//java/com/google/gerrit/testing:gerrit-test-util",
+    "//lib/guice",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+]
+
+QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
+
+TYPES = [
     "account",
     "change",
     "group",
     "project",
-]}
+]
+
+SUFFIX = "sTest.java"
+
+ELASTICSEARCH_TESTS = {i: "ElasticQuery" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V5 = {i: "ElasticV5Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V6 = {i: "ElasticV6Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TAGS = [
+    "docker",
+    "elastic",
+    "exclusive",
+]
 
 [junit_tests(
-    name = "elasticsearch_%ss_test" % name,
+    name = "elasticsearch_query_%ss_test" % name,
     size = "large",
     srcs = [src],
-    tags = [
-        "docker",
-        "elastic",
-    ],
-    deps = [
-        ":elasticsearch_test_utils",
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
-        "//lib/guice",
-        "//lib/httpcomponents:httpcore",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/testcontainers",
-    ],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
 ) for name, src in ELASTICSEARCH_TESTS.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V5" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V5.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V6" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS + ["flaky"],
+    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name],
+) for name, src in ELASTICSEARCH_TESTS_V6.items()]
+
+junit_tests(
+    name = "elasticsearch_tests",
+    size = "small",
+    srcs = glob(
+        ["*Test.java"],
+        exclude = ["Elastic*Query*" + SUFFIX],
+    ),
+    tags = ["elastic"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
new file mode 100644
index 0000000..559b8c7
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2018 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 static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_MAX_RETRY_TIMEOUT_MS;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_MAX_RETRY_TIMEOUT;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.MAX_RETRY_TIMEOUT_UNIT;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.ProvisionException;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class ElasticConfigurationTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void singleServerNoOtherConfig() throws Exception {
+    Config cfg = newConfig();
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:1234");
+    assertThat(esCfg.username).isNull();
+    assertThat(esCfg.password).isNull();
+    assertThat(esCfg.prefix).isEmpty();
+    assertThat(esCfg.maxRetryTimeout).isEqualTo(DEFAULT_MAX_RETRY_TIMEOUT_MS);
+  }
+
+  @Test
+  public void serverWithoutPortSpecified() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:9200");
+  }
+
+  @Test
+  public void prefix() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.prefix).isEqualTo("myprefix");
+  }
+
+  @Test
+  public void maxRetryTimeoutInDefaultUnit() {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45000");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.maxRetryTimeout).isEqualTo(45000);
+  }
+
+  @Test
+  public void maxRetryTimeoutInOtherUnit() {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45 s");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.maxRetryTimeout)
+        .isEqualTo(MAX_RETRY_TIMEOUT_UNIT.convert(45, TimeUnit.SECONDS));
+  }
+
+  @Test
+  public void withAuthentication() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo("myself");
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void multipleServers() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH,
+        null,
+        KEY_SERVER,
+        ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
+  }
+
+  @Test
+  public void noServers() throws Exception {
+    assertProvisionException(new Config());
+  }
+
+  @Test
+  public void singleServerInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
+    assertProvisionException(cfg);
+  }
+
+  @Test
+  public void multipleServersIncludingInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234");
+  }
+
+  private static Config newConfig() {
+    Config config = new Config();
+    config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
+    return config;
+  }
+
+  private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
+    assertThat(Arrays.asList(cfg.getHosts()).stream().map(h -> h.toURI()).collect(toList()))
+        .containsExactly(hostURIs);
+  }
+
+  private void assertProvisionException(Config cfg) throws Exception {
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("No valid Elasticsearch servers configured");
+    new ElasticConfiguration(cfg);
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index c78f7c0..b95a910 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -45,9 +45,13 @@
       case V2_4:
         return "elasticsearch:2.4.6-alpine";
       case V5_6:
-        return "elasticsearch:5.6.9-alpine";
+        return "docker.elastic.co/elasticsearch/elasticsearch:5.6.11";
       case V6_2:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4";
+      case V6_3:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2";
+      case V6_4:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.1";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
@@ -65,7 +69,7 @@
   }
 
   @Override
-  protected Set<Integer> getLivenessCheckPorts() {
+  public Set<Integer> getLivenessCheckPortNumbers() {
     return ImmutableSet.of(getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index ca52e2a..b46e040 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -32,13 +32,19 @@
     }
   }
 
-  public static void configure(Config config, int port, String prefix) {
+  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
     config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
-    config.setString("elasticsearch", "test", "protocol", "http");
-    config.setString("elasticsearch", "test", "hostname", "localhost");
-    config.setInt("elasticsearch", "test", "port", port);
+    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
     config.setString("elasticsearch", null, "prefix", prefix);
-    config.setString("index", null, "maxLimit", "10000");
+    config.setInt("index", null, "maxLimit", 10000);
+    String password = version == ElasticVersion.V5_6 ? "changeme" : null;
+    if (password != null) {
+      config.setString("elasticsearch", null, "password", password);
+    }
+  }
+
+  public static void configure(Config config, int port, String prefix) {
+    configure(config, port, prefix, null);
   }
 
   public static void createAllIndexes(Injector injector) throws IOException {
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index 649fc6f..5d2f944 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -67,7 +67,8 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index 4aa08fa..5d76162 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -67,7 +67,8 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index 72f8b49..9ce2e93 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -67,7 +67,8 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index 7b49e1d..4184935 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -67,7 +67,8 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index db710f6..b8154ce 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 043de4e..3445b36 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index b126c9d..851b27d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_2);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index e0da86a..b598a0a 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -29,17 +29,31 @@
     assertThat(ElasticVersion.forVersion("2.4.6")).isEqualTo(ElasticVersion.V2_4);
 
     assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
-    assertThat(ElasticVersion.forVersion("5.6.9")).isEqualTo(ElasticVersion.V5_6);
+    assertThat(ElasticVersion.forVersion("5.6.11")).isEqualTo(ElasticVersion.V5_6);
 
     assertThat(ElasticVersion.forVersion("6.2.0")).isEqualTo(ElasticVersion.V6_2);
     assertThat(ElasticVersion.forVersion("6.2.4")).isEqualTo(ElasticVersion.V6_2);
+
+    assertThat(ElasticVersion.forVersion("6.3.0")).isEqualTo(ElasticVersion.V6_3);
+    assertThat(ElasticVersion.forVersion("6.3.2")).isEqualTo(ElasticVersion.V6_3);
+
+    assertThat(ElasticVersion.forVersion("6.4.0")).isEqualTo(ElasticVersion.V6_4);
+    assertThat(ElasticVersion.forVersion("6.4.1")).isEqualTo(ElasticVersion.V6_4);
   }
 
   @Test
   public void unsupportedVersion() throws Exception {
-    exception.expect(ElasticVersion.InvalidVersion.class);
+    exception.expect(ElasticVersion.UnsupportedVersion.class);
     exception.expectMessage(
-        "Invalid version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
+        "Unsupported version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
     ElasticVersion.forVersion("4.0.0");
   }
+
+  @Test
+  public void version6() throws Exception {
+    assertThat(ElasticVersion.V6_2.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_3.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_4.isV6()).isTrue();
+    assertThat(ElasticVersion.V5_6.isV6()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index 117e474..c86160f 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.extensions.registration;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.inject.Key;
+import com.google.inject.Provider;
 import com.google.inject.util.Providers;
+import java.util.Iterator;
 import org.junit.Test;
 
 public class DynamicSetTest {
@@ -40,7 +43,7 @@
   @Test
   public void containsTrueWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     assertThat(ds.contains(2)).isTrue(); // See above comment about ds.contains
   }
@@ -48,7 +51,7 @@
   @Test
   public void containsFalseWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
@@ -56,8 +59,8 @@
   @Test
   public void containsTrueWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
-    ds.add(4);
+    ds.add("gerrit", 2);
+    ds.add("gerrit", 4);
 
     assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
   }
@@ -65,8 +68,8 @@
   @Test
   public void containsFalseWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
-    ds.add(4);
+    ds.add("gerrit", 2);
+    ds.add("gerrit", 4);
 
     assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
@@ -74,12 +77,12 @@
   @Test
   public void containsDynamic() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     Key<Integer> key = Key.get(Integer.class);
-    ReloadableRegistrationHandle<Integer> handle = ds.add(key, Providers.of(4));
+    ReloadableRegistrationHandle<Integer> handle = ds.add("gerrit", key, Providers.of(4));
 
-    ds.add(6);
+    ds.add("gerrit", 6);
 
     // At first, 4 is contained.
     assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
@@ -90,4 +93,49 @@
     // And now 4 should no longer be contained.
     assertThat(ds.contains(4)).isFalse(); // See above comment about ds.contains
   }
+
+  @Test
+  public void plugins() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    assertThat(ds.plugins()).containsExactly("bar", "foo").inOrder();
+  }
+
+  @Test
+  public void byPlugin() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    assertThat(ds.byPlugin("foo").stream().map(Provider::get).collect(toSet())).containsExactly(1);
+    assertThat(ds.byPlugin("bar").stream().map(Provider::get).collect(toSet()))
+        .containsExactly(2, 3);
+  }
+
+  @Test
+  public void entryIterator() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    Iterator<DynamicSet.Entry<Integer>> entryIterator = ds.entries().iterator();
+    DynamicSet.Entry<Integer> next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("foo");
+    assertThat(next.getProvider().get()).isEqualTo(1);
+
+    next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("bar");
+    assertThat(next.getProvider().get()).isEqualTo(2);
+
+    next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("bar");
+    assertThat(next.getProvider().get()).isEqualTo(3);
+
+    assertThat(entryIterator.hasNext()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index b5b942d..2fc06a6 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -26,6 +26,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.Iterators;
 import com.google.gerrit.gpg.testing.TestKey;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -163,6 +164,8 @@
     TestKey key5 = validKeyWithSecondUserId();
     PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
     PGPPublicKey key = keyRing.getPublicKey();
+    PGPPublicKey subKey =
+        keyRing.getPublicKey(Iterators.get(keyRing.getPublicKeys(), 1).getKeyID());
     store.add(keyRing);
     assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
 
@@ -171,9 +174,11 @@
         "Testuser Five <test5@example.com>",
         "foo:myId");
 
+    keyRing = PGPPublicKeyRing.removePublicKey(keyRing, subKey);
     keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
     key = PGPPublicKey.removeCertification(key, "foo:myId");
     keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
+    keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, subKey);
     store.add(keyRing);
     assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
 
diff --git a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
index ad8f4311..266f868 100644
--- a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -184,7 +184,7 @@
     }
 
     String cert = payload + new String(bout.toByteArray(), UTF_8);
-    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)));
+    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)), UTF_8);
     PushCertificateParser parser = new PushCertificateParser(repo, signedPushConfig);
     return parser.parse(reader);
   }
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 086dcc2..1c6559b0 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -76,7 +76,7 @@
    */
   private ReloadableRegistrationHandle<AllRequestFilter> addFilter(AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
-    return filters.add(key, Providers.of(filter));
+    return filters.add("gerrit", key, Providers.of(filter));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index edafeb3..307a23e 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.template.soy.data.SoyMapData;
 import java.net.URISyntaxException;
 import org.junit.Test;
 
 public class IndexServletTest {
-  class TestIndexServlet extends IndexServlet {
+  static class TestIndexServlet extends IndexServlet {
     private static final long serialVersionUID = 1L;
 
     TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
@@ -30,7 +31,7 @@
     }
 
     String getIndexSource() {
-      return new String(indexSource);
+      return new String(indexSource, UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index d905188..5597ed1 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -6,12 +6,12 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index:query_parser",
         "//lib:guava",
         "//lib:junit",
-        "//lib/antlr:java_runtime",
+        "//lib/antlr:java-runtime",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
index 88d8349..74eac61 100644
--- a/javatests/com/google/gerrit/index/query/NotPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
@@ -50,17 +50,26 @@
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
-    assertOnlyChild("clear", p, n);
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("clear", p, n);
+    }
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
-    assertOnlyChild("remove(0)", p, n);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove(0)", p, n);
+    }
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertOnlyChild("remove(0)", p, n);
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove()", p, n);
+    }
   }
 
   private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
diff --git a/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
similarity index 94%
rename from javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
rename to javatests/com/google/gerrit/mail/AbstractParserTest.java
index 0e894a6..6ab4ca2 100644
--- a/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.mail.Address;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -27,7 +26,8 @@
 
 @Ignore
 public class AbstractParserTest {
-  protected static final String CHANGE_URL = "https://gerrit-review.googlesource.com/#/changes/123";
+  protected static final String CHANGE_URL =
+      "https://gerrit-review.googlesource.com/c/project/+/123";
 
   protected static void assertChangeMessage(String message, MailComment comment) {
     assertThat(comment.fileName).isNull();
diff --git a/javatests/com/google/gerrit/server/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
similarity index 81%
rename from javatests/com/google/gerrit/server/mail/AddressTest.java
rename to javatests/com/google/gerrit/mail/AddressTest.java
index 7dbd563..53ff1fe 100644
--- a/javatests/com/google/gerrit/server/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.fail;
@@ -24,57 +24,57 @@
   @Test
   public void parse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_NameEmail2() {
     final Address a = Address.parse("A <a@b>");
-    assertThat(a.name).isEqualTo("A");
-    assertThat(a.email).isEqualTo("a@b");
+    assertThat(a.getName()).isEqualTo("A");
+    assertThat(a.getEmail()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NameEmail3() {
     final Address a = Address.parse("<a@b>");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NameEmail4() {
     final Address a = Address.parse("A U Thor<author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_NameEmail5() {
     final Address a = Address.parse("A U Thor  <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_Email1() {
     final Address a = Address.parse("author@example.com");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("author@example.com");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_Email2() {
     final Address a = Address.parse("a@b");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
+    assertThat(a.getName()).isNull();
+    assertThat(a.getEmail()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NewTLD() {
     Address a = Address.parse("A U Thor <author@example.systems>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.systems");
+    assertThat(a.getName()).isEqualTo("A U Thor");
+    assertThat(a.getEmail()).isEqualTo("author@example.systems");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/mail/BUILD b/javatests/com/google/gerrit/mail/BUILD
new file mode 100644
index 0000000..2fd8f24
--- /dev/null
+++ b/javatests/com/google/gerrit/mail/BUILD
@@ -0,0 +1,35 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "maillib_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/org/eclipse/jgit:server",
+        "//lib:gson",
+        "//lib:guava-retrying",
+        "//lib:gwtorm",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java b/javatests/com/google/gerrit/mail/GenericHtmlParserTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
rename to javatests/com/google/gerrit/mail/GenericHtmlParserTest.java
index f78953d..4718bad 100644
--- a/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/GenericHtmlParserTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 /** Test parser for a generic Html email client response */
 public class GenericHtmlParserTest extends HtmlParserTest {
diff --git a/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/javatests/com/google/gerrit/mail/GmailHtmlParserTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
rename to javatests/com/google/gerrit/mail/GmailHtmlParserTest.java
index df71629..f597dee 100644
--- a/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/GmailHtmlParserTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 public class GmailHtmlParserTest extends HtmlParserTest {
   @Override
diff --git a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
rename to javatests/com/google/gerrit/mail/HtmlParserTest.java
index d88e09f..d630bd6 100644
--- a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
rename to javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index 071dc4b..2d2c2ea 100644
--- a/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -12,12 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.MailHeader;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java b/javatests/com/google/gerrit/mail/ParserUtilTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
rename to javatests/com/google/gerrit/mail/ParserUtilTest.java
index dfa492c..47a5367 100644
--- a/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
+++ b/javatests/com/google/gerrit/mail/ParserUtilTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/javatests/com/google/gerrit/mail/RawMailParserTest.java
similarity index 83%
rename from javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
rename to javatests/com/google/gerrit/mail/RawMailParserTest.java
index fb52947..9049704 100644
--- a/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
+++ b/javatests/com/google/gerrit/mail/RawMailParserTest.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.mail.receive.data.AttachmentMessage;
-import com.google.gerrit.server.mail.receive.data.Base64HeaderMessage;
-import com.google.gerrit.server.mail.receive.data.HtmlMimeMessage;
-import com.google.gerrit.server.mail.receive.data.NonUTF8Message;
-import com.google.gerrit.server.mail.receive.data.QuotedPrintableHeaderMessage;
-import com.google.gerrit.server.mail.receive.data.RawMailMessage;
-import com.google.gerrit.server.mail.receive.data.SimpleTextMessage;
+import com.google.gerrit.mail.data.AttachmentMessage;
+import com.google.gerrit.mail.data.Base64HeaderMessage;
+import com.google.gerrit.mail.data.HtmlMimeMessage;
+import com.google.gerrit.mail.data.NonUTF8Message;
+import com.google.gerrit.mail.data.QuotedPrintableHeaderMessage;
+import com.google.gerrit.mail.data.RawMailMessage;
+import com.google.gerrit.mail.data.SimpleTextMessage;
 import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
similarity index 99%
rename from javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
rename to javatests/com/google/gerrit/mail/TextParserTest.java
index 89e1f22..a5c2152 100644
--- a/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive;
+package com.google.gerrit.mail;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -23,7 +23,7 @@
 public class TextParserTest extends AbstractParserTest {
   private static final String quotedFooter =
       ""
-          + "> To view, visit https://gerrit-review.googlesource.com/123\n"
+          + "> To view, visit https://gerrit-review.googlesource.com/c/project/+/123\n"
           + "> To unsubscribe, visit https://gerrit-review.googlesource.com\n"
           + "> \n"
           + "> Gerrit-MessageType: comment\n"
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
similarity index 95%
rename from javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
rename to javatests/com/google/gerrit/mail/data/AttachmentMessage.java
index eb4d180..1d94d68 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
+++ b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
similarity index 93%
rename from javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
rename to javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
index 91dc6f1..aa19537 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
similarity index 96%
rename from javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
rename to javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
index 756581f..1d68cc8 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
+++ b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
similarity index 93%
rename from javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
rename to javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index 3fafd4b..32915e7 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -11,10 +11,10 @@
 // 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.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
similarity index 93%
rename from javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
rename to javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
index 2dc48b5..47e813a 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java b/javatests/com/google/gerrit/mail/data/RawMailMessage.java
similarity index 89%
rename from javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
rename to javatests/com/google/gerrit/mail/data/RawMailMessage.java
index 2af82ad..53c782c 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
+++ b/javatests/com/google/gerrit/mail/data/RawMailMessage.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.MailMessage;
 import org.junit.Ignore;
 
 /** Base class for all email parsing tests. */
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
similarity index 97%
rename from javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
rename to javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index aa5b78a..a8f5b94 100644
--- a/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail.receive.data;
+package com.google.gerrit.mail.data;
 
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
diff --git a/javatests/com/google/gerrit/proto/BUILD b/javatests/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..0940f6b
--- /dev/null
+++ b/javatests/com/google/gerrit/proto/BUILD
@@ -0,0 +1,17 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "proto_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//lib/truth:truth-proto-extension",
+        "//proto:reviewdb_java_proto",
+
+        # TODO(dborowitz): These are already runtime_deps of
+        # truth-proto-extension, but either omitting them or adding them as
+        # runtime_deps to this target fails with:
+        #   class file for com.google.common.collect.Multimap not found
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/proto/ReviewDbProtoTest.java b/javatests/com/google/gerrit/proto/ReviewDbProtoTest.java
new file mode 100644
index 0000000..49cd2921
--- /dev/null
+++ b/javatests/com/google/gerrit/proto/ReviewDbProtoTest.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 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.proto;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb.Change;
+import com.google.gerrit.proto.reviewdb.Reviewdb.Change_Id;
+import org.junit.Test;
+
+public class ReviewDbProtoTest {
+  @Test
+  public void generatedProtoApi() {
+    Change c1 = Change.newBuilder().setChangeId(Change_Id.newBuilder().setId(1234).build()).build();
+    Change c2 = Change.newBuilder().setChangeId(Change_Id.newBuilder().setId(5678).build()).build();
+    assertThat(c1).isEqualTo(c1);
+    assertThat(c1).isNotEqualTo(c2);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 3113a8a..7be1827 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -30,6 +30,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = [
         "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
     ],
     deps = [
         ":custom-truth-subjects",
@@ -40,18 +41,21 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/group/testing",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//java/org/eclipse/jgit:server",
-        "//lib:grappa",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -60,6 +64,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 91cc2b7..6dd0f3e 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -55,7 +55,7 @@
     user = createNiceMock(IdentifiedUser.class);
     replay(user);
     backends = new DynamicSet<>();
-    backends.add(new SystemGroupBackend(new Config()));
+    backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend = new UniversalGroupBackend(backends);
   }
 
@@ -123,7 +123,7 @@
     replay(member, notMember, backend);
 
     backends = new DynamicSet<>();
-    backends.add(backend);
+    backends.add("gerrit", backend);
     backend = new UniversalGroupBackend(backends);
 
     GroupMembership checker = backend.membershipsOf(member);
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
new file mode 100644
index 0000000..f29ff1f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2018 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.externalids;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.inject.TypeLiteral;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class AllExternalIdsTest {
+  @Test
+  public void serializeEmptyExternalIds() throws Exception {
+    assertRoundTrip(allExternalIds(), AllExternalIdsProto.getDefaultInstance());
+  }
+
+  @Test
+  public void serializeMultipleExternalIds() throws Exception {
+    Account.Id accountId1 = new Account.Id(1001);
+    Account.Id accountId2 = new Account.Id(1002);
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create("scheme1", "id1", accountId1),
+            ExternalId.create("scheme2", "id2", accountId1),
+            ExternalId.create("scheme2", "id3", accountId2),
+            ExternalId.create("scheme3", "id4", accountId2)),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme1:id1").setAccountId(1001).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme2:id2").setAccountId(1001).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme2:id3").setAccountId(1002).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme3:id4").setAccountId(1002).build())
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithEmail() throws Exception {
+    assertRoundTrip(
+        allExternalIds(ExternalId.createEmail(new Account.Id(1001), "foo@example.com")),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("mailto:foo@example.com")
+                    .setAccountId(1001)
+                    .setEmail("foo@example.com"))
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithPassword() throws Exception {
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create("scheme", "id", new Account.Id(1001), null, "hashed password")),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("scheme:id")
+                    .setAccountId(1001)
+                    .setPassword("hashed password"))
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithBlobId() throws Exception {
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create(
+                ExternalId.create("scheme", "id", new Account.Id(1001)),
+                ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("scheme:id")
+                    .setAccountId(1001)
+                    .setBlobId(
+                        byteString(
+                            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+                            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef)))
+            .build());
+  }
+
+  @Test
+  public void allExternalIdsMethods() {
+    assertThatSerializedClass(AllExternalIds.class)
+        .hasAutoValueMethods(
+            ImmutableMap.of(
+                "byAccount",
+                    new TypeLiteral<ImmutableSetMultimap<Account.Id, ExternalId>>() {}.getType(),
+                "byEmail",
+                    new TypeLiteral<ImmutableSetMultimap<String, ExternalId>>() {}.getType()));
+  }
+
+  @Test
+  public void externalIdMethods() {
+    assertThatSerializedClass(ExternalId.class)
+        .hasAutoValueMethods(
+            ImmutableMap.of(
+                "key", ExternalId.Key.class,
+                "accountId", Account.Id.class,
+                "email", String.class,
+                "password", String.class,
+                "blobId", ObjectId.class));
+  }
+
+  private static AllExternalIds allExternalIds(ExternalId... externalIds) {
+    return AllExternalIds.create(Arrays.asList(externalIds));
+  }
+
+  private static void assertRoundTrip(
+      AllExternalIds allExternalIds, AllExternalIdsProto expectedProto) throws Exception {
+    AllExternalIdsProto actualProto =
+        AllExternalIdsProto.parseFrom(Serializer.INSTANCE.serialize(allExternalIds));
+    assertThat(actualProto).ignoringRepeatedFieldOrder().isEqualTo(expectedProto);
+    AllExternalIds actual =
+        Serializer.INSTANCE.deserialize(Serializer.INSTANCE.serialize(allExternalIds));
+    assertThat(actual).isEqualTo(allExternalIds);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 5e93a09..81fd6d7 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -6,8 +6,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.lang.reflect.Type;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index ab88169..4950266 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -5,16 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//lib:guava",
-        "//lib:gwtorm",
         "//lib:junit",
-        "//lib:protobuf",
-        "//lib/auto:auto-value",
-        "//lib/auto:auto-value-annotations",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
-        "//lib/truth:truth-proto-extension",
-        "//proto:cache_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 63ae94b..2ee8e48 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 4180192..147aeeb 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.cache.h2;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -43,8 +42,8 @@
         new SqlStore<>(
             "jdbc:h2:mem:Test_" + id,
             KEY_TYPE,
-            StringSerializer.INSTANCE,
-            StringSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
             version,
             1 << 20,
             null);
@@ -88,9 +87,9 @@
   @Test
   public void stringSerializer() {
     String input = "foo";
-    byte[] serialized = StringSerializer.INSTANCE.serialize(input);
+    byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
     assertThat(serialized).isEqualTo(new byte[] {'f', 'o', 'o'});
-    assertThat(StringSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
   }
 
   @Test
@@ -124,23 +123,6 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
-  // TODO(dborowitz): Won't be necessary when we use a real StringSerializer in the server code.
-  private enum StringSerializer implements CacheSerializer<String> {
-    INSTANCE;
-
-    @Override
-    public byte[] serialize(String object) {
-      return object.getBytes(UTF_8);
-    }
-
-    @Override
-    public String deserialize(byte[] in) {
-      // TODO(dborowitz): Consider using CharsetDecoder directly in the real implementation, to get
-      // checked exceptions.
-      return new String(in, UTF_8);
-    }
-  }
-
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..35d8527
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,20 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:junit",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index 3186620..7504850 100644
--- a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
index 60bbb16..0b80fc7 100644
--- a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
index 7a7c27c..987a62a 100644
--- a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
index 962b797..c2db808 100644
--- a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index 41d07b9..6596730 100644
--- a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
new file mode 100644
index 0000000..c56f8f8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2018 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.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdCacheSerializerTest {
+  @Test
+  public void serialize() {
+    ObjectId id = ObjectId.fromString("aabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+    byte[] serialized = ObjectIdCacheSerializer.INSTANCE.serialize(id);
+    assertThat(serialized)
+        .isEqualTo(
+            byteArray(
+                0xaa, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
+    assertThat(ObjectIdCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(id);
+  }
+
+  @Test
+  public void deserializeInvalid() {
+    assertDeserializeFails(null);
+    assertDeserializeFails(byteArray());
+    assertDeserializeFails(byteArray(0xaa));
+    assertDeserializeFails(
+        byteArray(
+            0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
+            0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa));
+  }
+
+  private void assertDeserializeFails(byte[] bytes) {
+    try {
+      ObjectIdCacheSerializer.INSTANCE.deserialize(bytes);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
similarity index 93%
rename from javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
index 8bf9762..69694fe 100644
--- a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -32,13 +32,13 @@
     ObjectIdConverter idConverter = ObjectIdConverter.create();
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa)))
         .isEqualTo(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb)))
         .isEqualTo(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
@@ -61,14 +61,14 @@
             idConverter.toByteString(
                 ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa));
     assertThat(
             idConverter.toByteString(
                 ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
new file mode 100644
index 0000000..fa3b7d7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2018 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.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.StandardCharsets;
+import org.junit.Test;
+
+public class StringCacheSerializerTest {
+  @Test
+  public void serialize() {
+    assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.serialize("abc"))
+        .isEqualTo(new byte[] {'a', 'b', 'c'});
+    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c"))
+        .isEqualTo(new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'});
+  }
+
+  @Test
+  public void serializeInvalidChar() {
+    // Can't use UTF-8 for the test, since it can encode all Unicode code points.
+    try {
+      StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
+      assert_().fail("expected IllegalStateException");
+    } catch (IllegalStateException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
+    }
+  }
+
+  @Test
+  public void deserialize() {
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'}))
+        .isEqualTo("abc");
+    assertThat(
+            StringCacheSerializer.INSTANCE.deserialize(
+                new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'}))
+        .isEqualTo("a\u1234c");
+  }
+
+  @Test
+  public void deserializeInvalidChar() {
+    try {
+      StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
+      assert_().fail("expected IllegalStateException");
+    } catch (IllegalStateException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 03e0d4e..b847ed7 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -39,9 +39,9 @@
     assertThat(ChangeKindKeyProto.parseFrom(serialized))
         .isEqualTo(
             ChangeKindKeyProto.newBuilder()
-                .setPrior(bytes(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+                .setPrior(byteString(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
                 .setNext(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setStrategyName("aStrategy")
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
index e91c3b4..dca2dcb 100644
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -27,7 +26,6 @@
 import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -55,9 +53,6 @@
   private RevCommit commit_v1_3;
   private RevCommit commit_v2_5;
 
-  private List<String> expTags = new ArrayList<>();
-  private List<String> expBranches = new ArrayList<>();
-
   private RevWalk revWalk;
 
   @Override
@@ -140,12 +135,8 @@
     IncludedInResolver.Result detail = resolve(commit_v2_5);
 
     // Check that only tags and branches which refer the tip are returned
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags()).containsExactly(TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches()).containsExactly(BRANCH_2_5);
   }
 
   @Test
@@ -154,22 +145,18 @@
     IncludedInResolver.Result detail = resolve(commit_initial);
 
     // Check whether all tags and branches are returned
-    expTags.add(TAG_1_0);
-    expTags.add(TAG_1_0_1);
-    expTags.add(TAG_1_3);
-    expTags.add(TAG_2_0);
-    expTags.add(TAG_2_0_1);
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-
-    expBranches.add(BRANCH_MASTER);
-    expBranches.add(BRANCH_1_0);
-    expBranches.add(BRANCH_1_3);
-    expBranches.add(BRANCH_2_0);
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags())
+        .containsExactly(
+            TAG_1_0,
+            TAG_1_0_1,
+            TAG_1_3,
+            TAG_2_0,
+            TAG_2_0_1,
+            TAG_2_5,
+            TAG_2_5_ANNOTATED,
+            TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches())
+        .containsExactly(BRANCH_MASTER, BRANCH_1_0, BRANCH_1_3, BRANCH_2_0, BRANCH_2_5);
   }
 
   @Test
@@ -178,27 +165,15 @@
     IncludedInResolver.Result detail = resolve(commit_v1_3);
 
     // Check whether all succeeding tags and branches are returned
-    expTags.add(TAG_1_3);
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-
-    expBranches.add(BRANCH_1_3);
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags())
+        .containsExactly(TAG_1_3, TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches()).containsExactly(BRANCH_1_3, BRANCH_2_5);
   }
 
   private IncludedInResolver.Result resolve(RevCommit commit) throws Exception {
     return IncludedInResolver.resolve(db, revWalk, commit);
   }
 
-  private void assertEquals(List<String> list1, List<String> list2) {
-    Collections.sort(list1);
-    Collections.sort(list2);
-    Assert.assertEquals(list1, list2);
-  }
-
   private void createAndCheckoutBranch(ObjectId objectId, String branchName) throws IOException {
     String fullBranchName = "refs/heads/" + branchName;
     super.createBranch(objectId, fullBranchName);
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index c8e6f2b..e10a236 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -39,11 +39,11 @@
         .isEqualTo(
             MergeabilityKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setInto(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index 853db27..b4cde14 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 
-import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 834f658..08d7082 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
@@ -43,7 +44,43 @@
     private boolean allowValueQueries = true;
 
     @Override
-    public CurrentUser user() {
+    public String resourcePath() {
+      return "/projects/test-project";
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      assertThat(allowValueQueries).isTrue();
+      return ImmutableSet.of(ProjectPermission.READ);
+    }
+
+    @Override
+    public BooleanCondition testCond(ProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm, fakeUser());
+    }
+
+    @Override
+    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    private void disallowValueQueries() {
+      allowValueQueries = false;
+    }
+
+    private static CurrentUser fakeUser() {
       return new CurrentUser() {
         @Override
         public GroupMembership getEffectiveGroups() {
@@ -66,48 +103,6 @@
         }
       };
     }
-
-    @Override
-    public String resourcePath() {
-      return "/projects/test-project";
-    }
-
-    @Override
-    public ForProject user(CurrentUser user) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public ForProject absentUser(Account.Id id) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public ForRef ref(String ref) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
-        throws PermissionBackendException {
-      assertThat(allowValueQueries).isTrue();
-      return ImmutableSet.of(ProjectPermission.READ);
-    }
-
-    @Override
-    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
-        throws PermissionBackendException {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    private void disallowValueQueries() {
-      allowValueQueries = false;
-    }
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index aaad2a6..4e0cb0c 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -19,7 +19,7 @@
 
 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.server.ioutil.HostPlatform;
 import com.google.gerrit.testing.TempFileUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.StandardKeyEncoder;
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
new file mode 100644
index 0000000..3d3e734
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import org.junit.Test;
+
+public class TagSetHolderTest {
+  @Test
+  public void serializerWithTagSet() throws Exception {
+    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+    holder.setTagSet(new TagSet(holder.getProjectName()));
+
+    byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
+    assertThat(TagSetHolderProto.parseFrom(serialized))
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(
+            TagSetHolderProto.newBuilder()
+                .setProjectName("project")
+                .setTags(holder.getTagSet().toProto())
+                .build());
+
+    TagSetHolder deserialized = TagSetHolder.Serializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized.getProjectName()).isEqualTo(holder.getProjectName());
+    TagSetTest.assertEqual(holder.getTagSet(), deserialized.getTagSet());
+  }
+
+  @Test
+  public void serializerWithoutTagSet() throws Exception {
+    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+
+    byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
+    assertThat(TagSetHolderProto.parseFrom(serialized))
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(TagSetHolderProto.newBuilder().setProjectName("project").build());
+
+    TagSetHolder deserialized = TagSetHolder.Serializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized.getProjectName()).isEqualTo(holder.getProjectName());
+    TagSetTest.assertEqual(holder.getTagSet(), deserialized.getTagSet());
+  }
+
+  @Test
+  public void fields() {
+    assertThatSerializedClass(TagSetHolder.class)
+        .hasFields(
+            ImmutableMap.of(
+                "buildLock", Object.class,
+                "projectName", Project.NameKey.class,
+                "tags", TagSet.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
new file mode 100644
index 0000000..1eebe75
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -0,0 +1,188 @@
+// Copyright (C) 2018 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
+import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.git.TagSet.CachedRef;
+import com.google.gerrit.server.git.TagSet.Tag;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectIdOwnerMap;
+import org.junit.Test;
+
+public class TagSetTest {
+  @Test
+  public void roundTripToProto() {
+    HashMap<String, CachedRef> refs = new HashMap<>();
+    refs.put(
+        "refs/heads/master",
+        new CachedRef(1, ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")));
+    refs.put(
+        "refs/heads/branch",
+        new CachedRef(2, ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")));
+    ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
+    tags.add(
+        new Tag(
+            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"), newBitSet(1, 3, 5)));
+    tags.add(
+        new Tag(
+            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
+    TagSet tagSet = new TagSet(new Project.NameKey("project"), refs, tags);
+
+    TagSetProto proto = tagSet.toProto();
+    assertThat(proto)
+        .ignoringRepeatedFieldOrder()
+        .isEqualTo(
+            TagSetProto.newBuilder()
+                .setProjectName("project")
+                .putRef(
+                    "refs/heads/master",
+                    CachedRefProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
+                                0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa))
+                        .setFlag(1)
+                        .build())
+                .putRef(
+                    "refs/heads/branch",
+                    CachedRefProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+                                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb))
+                        .setFlag(2)
+                        .build())
+                .addTag(
+                    TagProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
+                                0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
+                        .setFlags(byteString(0x2a))
+                        .build())
+                .addTag(
+                    TagProto.newBuilder()
+                        .setId(
+                            byteString(
+                                0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
+                                0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
+                        .setFlags(byteString(0x54))
+                        .build())
+                .build());
+
+    assertEqual(tagSet, TagSet.fromProto(proto));
+  }
+
+  @Test
+  public void tagSetFields() {
+    assertThatSerializedClass(TagSet.class)
+        .hasFields(
+            ImmutableMap.of(
+                "projectName", Project.NameKey.class,
+                "refs", new TypeLiteral<Map<String, CachedRef>>() {}.getType(),
+                "tags", new TypeLiteral<ObjectIdOwnerMap<Tag>>() {}.getType()));
+  }
+
+  @Test
+  public void cachedRefFields() {
+    assertThatSerializedClass(CachedRef.class)
+        .extendsClass(new TypeLiteral<AtomicReference<ObjectId>>() {}.getType());
+    assertThatSerializedClass(CachedRef.class)
+        .hasFields(
+            ImmutableMap.of(
+                "flag", int.class, "value", AtomicReference.class.getTypeParameters()[0]));
+  }
+
+  @Test
+  public void tagFields() {
+    assertThatSerializedClass(Tag.class).extendsClass(ObjectIdOwnerMap.Entry.class);
+    assertThatSerializedClass(Tag.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("refFlags", BitSet.class)
+                .put("next", ObjectIdOwnerMap.Entry.class)
+                .put("w1", int.class)
+                .put("w2", int.class)
+                .put("w3", int.class)
+                .put("w4", int.class)
+                .put("w5", int.class)
+                .build());
+  }
+
+  // TODO(dborowitz): Find some more common place to put this method, which requires access to
+  // package-private TagSet details.
+  static void assertEqual(@Nullable TagSet a, @Nullable TagSet b) {
+    if (a == null || b == null) {
+      assertWithMessage("only one TagSet is null out of\n%s\n%s", a, b)
+          .that(a == null && b == null)
+          .isTrue();
+      return;
+    }
+    assertThat(a.getProjectName()).isEqualTo(b.getProjectName());
+
+    Map<String, CachedRef> aRefs = a.getRefsForTesting();
+    Map<String, CachedRef> bRefs = b.getRefsForTesting();
+    assertThat(ImmutableSortedSet.copyOf(aRefs.keySet()))
+        .named("ref name set")
+        .isEqualTo(ImmutableSortedSet.copyOf(bRefs.keySet()));
+    for (String name : aRefs.keySet()) {
+      CachedRef aRef = aRefs.get(name);
+      CachedRef bRef = bRefs.get(name);
+      assertThat(aRef.get()).named("value of ref %s", name).isEqualTo(bRef.get());
+      assertThat(aRef.flag).named("flag of ref %s", name).isEqualTo(bRef.flag);
+    }
+
+    ObjectIdOwnerMap<Tag> aTags = a.getTagsForTesting();
+    ObjectIdOwnerMap<Tag> bTags = b.getTagsForTesting();
+    assertThat(getTagIds(aTags)).named("tag ID set").isEqualTo(getTagIds(bTags));
+    for (Tag aTag : aTags) {
+      Tag bTag = bTags.get(aTag);
+      assertThat(aTag.refFlags).named("flags for tag %s", aTag.name()).isEqualTo(bTag.refFlags);
+    }
+  }
+
+  private static ImmutableSortedSet<String> getTagIds(ObjectIdOwnerMap<Tag> bTags) {
+    return Streams.stream(bTags)
+        .map(Tag::name)
+        .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.naturalOrder()));
+  }
+
+  private BitSet newBitSet(int... bits) {
+    BitSet result = new BitSet();
+    Arrays.stream(bits).forEach(result::set);
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6090a78..56e53b9 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -203,7 +203,7 @@
 
   private MyMetaData load(String ref, int expectedValue) throws Exception {
     MyMetaData d = new MyMetaData(ref);
-    d.load(repo);
+    d.load(project, repo);
     assertThat(d.getValue()).isEqualTo(expectedValue);
     return d;
   }
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 2aa6035..943f784 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -140,7 +140,7 @@
           @Override
           public String getName() {
             try {
-              return GroupConfig.loadForGroup(allUsersRepo, uuid)
+              return GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid)
                   .getLoadedGroup()
                   .map(InternalGroup::getName)
                   .orElse("Group " + uuid);
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 4e93aee..309d710 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -37,7 +37,7 @@
 
   @Before
   public void setUp() throws Exception {
-    auditLogReader = new AuditLogReader(SERVER_ID);
+    auditLogReader = new AuditLogReader(SERVER_ID, allUsersName);
   }
 
   @Test
@@ -250,7 +250,8 @@
                 .setMemberModification(members -> ImmutableSet.of(authorId))
                 .build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig =
+        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
 
     groupConfig.commit(createMetaDataUpdate(authorIdent));
@@ -261,7 +262,7 @@
 
   private void updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
     groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
     groupConfig.commit(createMetaDataUpdate(userIdent));
   }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index d03a38b..dec1d63 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -65,6 +65,7 @@
 
   @Rule public ExpectedException expectedException = ExpectedException.none();
 
+  private Project.NameKey projectName;
   private Repository repository;
   private TestRepository<?> testRepository;
   private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
@@ -76,6 +77,7 @@
 
   @Before
   public void setUp() throws Exception {
+    projectName = new Project.NameKey("Test Repository");
     repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
     testRepository = new TestRepository<>(repository);
   }
@@ -117,7 +119,7 @@
   public void nameOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -130,7 +132,7 @@
   public void nameOfNewGroupMustNotBeNull() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -152,7 +154,7 @@
   public void idOfNewGroupMustNotBeNegative() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -230,7 +232,7 @@
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
@@ -245,7 +247,7 @@
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
@@ -362,7 +364,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -372,7 +374,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -398,7 +400,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -546,7 +548,7 @@
   public void nameCannotBeUpdatedToNull() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -562,7 +564,7 @@
   public void nameCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -579,7 +581,7 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
     InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(emptyName).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -629,7 +631,7 @@
   public void ownerGroupUuidCannotBeUpdatedToNull() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -645,7 +647,7 @@
   public void ownerGroupUuidCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -874,7 +876,7 @@
   public void groupConfigMayBeReusedForFurtherUpdates() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).setId(groupId).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
 
     AccountGroup.NameKey name = new AccountGroup.NameKey("Robots");
@@ -1054,7 +1056,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1072,7 +1074,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("A test group").build();
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1138,7 +1140,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent committerIdent =
@@ -1171,7 +1173,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent authorIdent =
@@ -1230,7 +1232,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent committerIdent =
@@ -1258,7 +1260,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent authorIdent =
@@ -1299,7 +1301,8 @@
     updateGroup(groupUuid, groupUpdate2);
 
     GroupConfig groupConfig =
-        GroupConfig.loadForGroupSnapshot(repository, groupUuid, commitAfterUpdate1.copy());
+        GroupConfig.loadForGroupSnapshot(
+            projectName, repository, groupUuid, commitAfterUpdate1.copy());
     Optional<InternalGroup> group = groupConfig.getLoadedGroup();
     assertThatGroup(group).value().nameKey().isEqualTo(firstName);
     assertThatGroup(group).value().refState().isEqualTo(commitAfterUpdate1.copy());
@@ -1344,7 +1347,7 @@
             .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
             .build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1369,7 +1372,7 @@
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1584,14 +1587,14 @@
 
   private Optional<InternalGroup> createGroup(InternalGroupCreation groupCreation)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
   private Optional<InternalGroup> createGroup(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate) throws Exception {
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
@@ -1605,14 +1608,14 @@
   private Optional<InternalGroup> updateGroup(
       AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
   private Optional<InternalGroup> loadGroup(AccountGroup.UUID uuid) throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
     return groupConfig.getLoadedGroup();
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3616e0e..a70df6b 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -84,12 +85,14 @@
   private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
 
   private AtomicInteger idCounter;
+  private AllUsersName allUsersName;
   private Repository repo;
 
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
     idCounter = new AtomicInteger();
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repo = new InMemoryRepository(new DfsRepositoryDescription(AllUsersNameProvider.DEFAULT));
   }
 
@@ -110,13 +113,13 @@
   @Test
   public void uuidOfNewGroupMustNotBeNull() throws Exception {
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, null, groupName);
+    GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName);
   }
 
   @Test
   public void nameOfNewGroupMustNotBeNull() throws Exception {
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, groupUuid, null);
+    GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null);
   }
 
   @Test
@@ -135,7 +138,7 @@
     AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("AnotherGroup");
     expectedException.expect(OrmDuplicateKeyException.class);
     expectedException.expectMessage(groupName.get());
-    GroupNameNotes.forNewGroup(repo, anotherGroupUuid, groupName);
+    GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName);
   }
 
   @Test
@@ -179,7 +182,7 @@
     createGroup(groupUuid, groupName);
 
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, groupName, null);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null);
   }
 
   @Test
@@ -188,7 +191,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, null, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName);
   }
 
   @Test
@@ -199,7 +202,7 @@
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage(anotherOldName.get());
-    GroupNameNotes.forRename(repo, groupUuid, anotherOldName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, anotherOldName, anotherName);
   }
 
   @Test
@@ -211,7 +214,7 @@
 
     expectedException.expect(OrmDuplicateKeyException.class);
     expectedException.expectMessage(anotherGroupName.get());
-    GroupNameNotes.forRename(repo, groupUuid, groupName, anotherGroupName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherGroupName);
   }
 
   @Test
@@ -220,7 +223,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, null, groupName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName);
   }
 
   @Test
@@ -231,7 +234,7 @@
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage(groupUuid.get());
-    GroupNameNotes.forRename(repo, anotherGroupUuid, groupName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, anotherGroupUuid, groupName, anotherName);
   }
 
   @Test
@@ -287,7 +290,8 @@
 
   @Test
   public void newCommitIsNotCreatedWhenCommittingGroupCreationTwice() throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forNewGroup(repo, groupUuid, groupName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, groupName);
 
     commit(groupNameNotes);
     ImmutableList<CommitInfo> commitsAfterFirstCommit = log();
@@ -303,7 +307,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     GroupNameNotes groupNameNotes =
-        GroupNameNotes.forRename(repo, groupUuid, groupName, anotherName);
+        GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherName);
 
     commit(groupNameNotes);
     ImmutableList<CommitInfo> commitsAfterFirstCommit = log();
@@ -504,14 +508,16 @@
 
   private void createGroup(AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
       throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forNewGroup(repo, groupUuid, groupName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, groupName);
     commit(groupNameNotes);
   }
 
   private void renameGroup(
       AccountGroup.UUID groupUuid, AccountGroup.NameKey oldName, AccountGroup.NameKey newName)
       throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forRename(repo, groupUuid, oldName, newName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forRename(allUsersName, repo, groupUuid, oldName, newName);
     commit(groupNameNotes);
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index acb33e9..51bda66 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -24,11 +24,11 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.RefState;
 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.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.testing.GerritBaseTests;
diff --git a/javatests/com/google/gerrit/server/ioutil/BUILD b/javatests/com/google/gerrit/server/ioutil/BUILD
index 721c6f9..ac9530f 100644
--- a/javatests/com/google/gerrit/server/ioutil/BUILD
+++ b/javatests/com/google/gerrit/server/ioutil/BUILD
@@ -10,5 +10,8 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/server/ioutil",
+        "//lib:guava",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
new file mode 100644
index 0000000..9bb6951
--- /dev/null
+++ b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 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.ioutil;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class HexFormatTest {
+
+  @Test
+  public void fromInt() {
+    assertEquals("0000000f", HexFormat.fromInt(0xf));
+    assertEquals("801234ab", HexFormat.fromInt(0x801234ab));
+    assertEquals("deadbeef", HexFormat.fromInt(0xdeadbeef));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
similarity index 95%
rename from javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
rename to javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
index 01964a8..3043985 100644
--- a/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2018 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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.ioutil;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
new file mode 100644
index 0000000..5117c01
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 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.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.truth.Expect;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class LoggingContextAwareExecutorServiceTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Test
+  public void loggingContextPropagationToBackgroundThread() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+
+      ExecutorService executor =
+          new LoggingContextAwareExecutorService(Executors.newFixedThreadPool(1));
+      executor
+          .submit(
+              () -> {
+                // Verify that the tags and force logging flag have been propagated to the new
+                // thread.
+                SortedMap<String, SortedSet<Object>> threadTagMap =
+                    LoggingContext.getInstance().getTags().asMap();
+                expect.that(threadTagMap.keySet()).containsExactly("foo");
+                expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                expect
+                    .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+                    .isTrue();
+              })
+          .get();
+
+      // Verify that tags and force logging flag in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
new file mode 100644
index 0000000..4fadbb4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2018 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.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MutableTagsTest {
+  private MutableTags tags;
+
+  @Before
+  public void setup() {
+    tags = new MutableTags();
+  }
+
+  @Test
+  public void addTag() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void addTagsWithDifferentName() {
+    assertThat(tags.add("name1", "value1")).isTrue();
+    assertThat(tags.add("name2", "value2")).isTrue();
+    assertTags(
+        ImmutableMap.of("name1", ImmutableSet.of("value1"), "name2", ImmutableSet.of("value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameButDifferentValues() {
+    assertThat(tags.add("name", "value1")).isTrue();
+    assertThat(tags.add("name", "value2")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value1", "value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameAndSameValue() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertThat(tags.add("name", "value")).isFalse();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void getEmptyTags() {
+    assertThat(tags.getTags().isEmpty()).isTrue();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void isEmpty() {
+    assertThat(tags.isEmpty()).isTrue();
+
+    tags.add("foo", "bar");
+    assertThat(tags.isEmpty()).isFalse();
+
+    tags.remove("foo", "bar");
+    assertThat(tags.isEmpty()).isTrue();
+  }
+
+  @Test
+  public void removeTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.remove("name2", "value");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value1", "value2")));
+
+    tags.remove("name1", "value1");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value2")));
+
+    tags.remove("name1", "value2");
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void removeNonExistingTag() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("foo", "bar");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("name", "foo");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void setTags() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertTags(
+        ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+  }
+
+  @Test
+  public void asMap() {
+    tags.add("name", "value");
+    assertThat(tags.asMap()).containsExactlyEntriesIn(ImmutableSetMultimap.of("name", "value"));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertThat(tags.asMap())
+        .containsExactlyEntriesIn(
+            ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+  }
+
+  @Test
+  public void clearTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.clear();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.add(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.add("foo", null));
+  }
+
+  @Test
+  public void removeInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.remove(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.remove("foo", null));
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertNullPointerException(String expectedMessage, Runnable r) {
+    try {
+      r.run();
+      assert_().fail("expected NullPointerException");
+    } catch (NullPointerException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
new file mode 100644
index 0000000..044d237
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -0,0 +1,264 @@
+// Copyright (C) 2018 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.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.After;
+import org.junit.Test;
+
+public class TraceContextTest {
+  @After
+  public void cleanup() {
+    LoggingContext.getInstance().clearTags();
+    LoggingContext.getInstance().forceLogging(false);
+  }
+
+  @Test
+  public void openContext() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContexts() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("abc", "xyz")) {
+        assertTags(ImmutableMap.of("abc", ImmutableSet.of("xyz"), "foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagName() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "baz")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar", "baz")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagNameAndValue() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "bar")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openContextWithRequestId() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag(RequestId.Type.RECEIVE_ID, "foo")) {
+      assertTags(ImmutableMap.of("RECEIVE_ID", ImmutableSet.of("foo")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addTag() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      traceContext.addTag("foo", "baz");
+      traceContext.addTag("bar", "baz");
+      assertTags(
+          ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openContextWithForceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging()) {
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void openNestedContextsWithForceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging()) {
+      assertForceLogging(true);
+
+      try (TraceContext traceContext2 = TraceContext.open()) {
+        // force logging is still enabled since outer trace context forced logging
+        assertForceLogging(true);
+
+        try (TraceContext traceContext3 = TraceContext.open().forceLogging()) {
+          assertForceLogging(true);
+        }
+
+        assertForceLogging(true);
+      }
+
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void forceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open()) {
+      assertForceLogging(false);
+
+      traceContext.forceLogging();
+      assertForceLogging(true);
+
+      traceContext.forceLogging();
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void newTrace() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(true, null, traceIdConsumer)) {
+      assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().getTagsAsMap().keySet())
+          .containsExactly(RequestId.Type.TRACE_ID.name());
+    }
+    assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+    assertThat(traceIdConsumer.traceId).isNotNull();
+  }
+
+  @Test
+  public void newTraceWithProvidedTraceId() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    String traceId = "foo";
+    try (TraceContext traceContext = TraceContext.newTrace(true, traceId, traceIdConsumer)) {
+      assertForceLogging(true);
+      assertTags(ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(traceId)));
+    }
+    assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+    assertThat(traceIdConsumer.traceId).isEqualTo(traceId);
+  }
+
+  @Test
+  public void newTraceDisabled() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(false, null, traceIdConsumer)) {
+      assertForceLogging(false);
+      assertTags(ImmutableMap.of());
+    }
+    assertThat(traceIdConsumer.tagName).isNull();
+    assertThat(traceIdConsumer.traceId).isNull();
+  }
+
+  @Test
+  public void newTraceDisabledWithProvidedTraceId() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(false, "foo", traceIdConsumer)) {
+      assertForceLogging(false);
+      assertTags(ImmutableMap.of());
+    }
+    assertThat(traceIdConsumer.tagName).isNull();
+    assertThat(traceIdConsumer.traceId).isNull();
+  }
+
+  @Test
+  public void onlyOneTraceId() {
+    TestTraceIdConsumer traceIdConsumer1 = new TestTraceIdConsumer();
+    try (TraceContext traceContext1 = TraceContext.newTrace(true, null, traceIdConsumer1)) {
+      String expectedTraceId = traceIdConsumer1.traceId;
+      assertThat(expectedTraceId).isNotNull();
+
+      TestTraceIdConsumer traceIdConsumer2 = new TestTraceIdConsumer();
+      try (TraceContext traceContext2 = TraceContext.newTrace(true, null, traceIdConsumer2)) {
+        assertForceLogging(true);
+        assertTags(
+            ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(expectedTraceId)));
+      }
+      assertThat(traceIdConsumer2.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+      assertThat(traceIdConsumer2.traceId).isEqualTo(expectedTraceId);
+    }
+  }
+
+  @Test
+  public void multipleTraceIdsIfTraceIdProvided() {
+    String traceId1 = "foo";
+    try (TraceContext traceContext1 =
+        TraceContext.newTrace(true, traceId1, (tagName, traceId) -> {})) {
+      TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+      String traceId2 = "bar";
+      try (TraceContext traceContext2 = TraceContext.newTrace(true, traceId2, traceIdConsumer)) {
+        assertForceLogging(true);
+        assertTags(
+            ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(traceId1, traceId2)));
+      }
+      assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+      assertThat(traceIdConsumer.traceId).isEqualTo(traceId2);
+    }
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap =
+        LoggingContext.getInstance().getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+
+  private static class TestTraceIdConsumer implements TraceIdConsumer {
+    String tagName;
+    String traceId;
+
+    @Override
+    public void accept(String tagName, String traceId) {
+      this.tagName = tagName;
+      this.traceId = traceId;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index a7234f4..f8a613a 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -16,7 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.testing.GerritBaseTests;
 import java.time.Instant;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 7028bb2..f99c356 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -22,12 +22,12 @@
 import static org.easymock.EasyMock.verify;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.mail.Address;
 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.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.mail.Address;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index c677be5..cc7302f 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -79,24 +79,10 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest extends GerritBaseTests {
-  @ConfigSuite.Default
-  public static Config changeNotesLegacy() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", false);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config changeNotesJson() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", true);
-    return cfg;
-  }
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
-
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -172,7 +158,6 @@
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
                 bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
-
                 MutableNotesMigration migration = MutableNotesMigration.newDisabled();
                 migration.setFrom(NotesMigrationState.FINAL);
                 bind(MutableNotesMigration.class).toInstance(migration);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index 5a7d812..7b140b7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -41,7 +41,7 @@
                 .setProject("project")
                 .setChangeId(1234)
                 .setId(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index de964d8..313fa07 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -498,11 +498,7 @@
   private RevCommit writeCommit(String body) throws Exception {
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
-        body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
-        false);
+        body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
   }
 
   private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
@@ -513,9 +509,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 3d65eae..7b41ba3 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableList;
@@ -30,6 +30,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -41,13 +42,12 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
 import com.google.gwtorm.client.KeyUtil;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 74ba0c2..8cd2753 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -35,6 +35,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -50,25 +51,21 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-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.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -79,7 +76,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
   @Inject private LegacyChangeNoteRead legacyChangeNoteRead;
-  @Inject private ChangeNoteUtil noteUtil;
 
   @Inject private @GerritServerId String serverId;
 
@@ -430,7 +426,7 @@
   @Test
   public void approvalsPostSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -465,7 +461,7 @@
   @Test
   public void approvalsDuringSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -602,7 +598,7 @@
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
@@ -644,7 +640,7 @@
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
     update.merge(
@@ -695,7 +691,7 @@
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(update.getResult());
       rw.parseBody(commit);
-      String strIdent = otherUser.getName() + " <" + otherUserId + "@" + serverId + ">";
+      String strIdent = "Gerrit User " + otherUserId + " <" + otherUserId + "@" + serverId + ">";
       assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
     }
   }
@@ -945,7 +941,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        RequestId.forChange(c),
+        submissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -999,7 +995,7 @@
     update.commit();
 
     try {
-      notes = newNotes(c);
+      newNotes(c);
       fail("Expected IOException");
     } catch (OrmException e) {
       assertCause(
@@ -1149,10 +1145,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    String note = readNote(notes, commit);
-    if (!testJson()) {
-      assertThat(note).isEqualTo(pushCert);
-    }
+    readNote(notes, commit);
+
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
@@ -1185,27 +1179,6 @@
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
-
-    if (!testJson()) {
-      assertThat(readNote(notes, commit))
-          .isEqualTo(
-              pushCert
-                  + "Revision: "
-                  + commit.name()
-                  + "\n"
-                  + "Patch-set: 2\n"
-                  + "File: a.txt\n"
-                  + "\n"
-                  + "1:2-3:4\n"
-                  + NoteDbUtil.formatTime(serverIdent, ts)
-                  + "\n"
-                  + "Author: Change Owner <1@gerrit>\n"
-                  + "Unresolved: false\n"
-                  + "UUID: uuid1\n"
-                  + "Bytes: 7\n"
-                  + "Comment\n"
-                  + "\n");
-    }
   }
 
   @Test
@@ -1591,307 +1564,6 @@
   }
 
   @Test
-  public void patchLineCommentNotesFormatSide1() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String uuid3 = "uuid3";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    String message3 = "comment 3";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    Timestamp time3 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    Comment comment3 =
-        newComment(
-            psId,
-            "file2",
-            uuid3,
-            range3,
-            range3.getEndLine(),
-            otherUser,
-            null,
-            time3,
-            message3,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "File: file2\n"
-                    + "\n"
-                    + "3:0-4:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time3)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatSide0() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            uuid1,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            true);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Parent: uuid1\n"
-                    + "Unresolved: true\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
   public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
@@ -1959,54 +1631,6 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "Base-for-patch-set: 2\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
         .isEqualTo(
             ImmutableListMultimap.of(
@@ -2048,32 +1672,6 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Real-author: Change Owner <1@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
@@ -2112,32 +1710,6 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Weird\u0002User <3@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
         .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
   }
@@ -2604,7 +2176,6 @@
     assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(psId);
     update.deleteComment(comment);
     update.commit();
@@ -2674,7 +2245,6 @@
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(ps2);
     update.deleteComment(comment2);
     update.commit();
@@ -3525,10 +3095,6 @@
     update.commit();
   }
 
-  private boolean testJson() {
-    return noteUtil.getChangeNoteJson().getWriteJson();
-  }
-
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
     ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
     return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
@@ -3575,4 +3141,8 @@
     update.commit();
     return tr.parseBody(commit);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java b/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java
new file mode 100644
index 0000000..2031a14
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java
@@ -0,0 +1,530 @@
+// Copyright (C) 2017 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.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimaps;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+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.client.RevId;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.notedb.CommentJsonMigrator.ProjectMigrationResult;
+import com.google.gerrit.testing.TestChanges;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CommentJsonMigratorTest extends AbstractChangeNotesTest {
+  private CommentJsonMigrator migrator;
+  @Inject private ChangeNoteUtil noteUtil;
+  @Inject private CommentsUtil commentsUtil;
+  @Inject private LegacyChangeNoteWrite legacyChangeNoteWrite;
+  @Inject private AllUsersName allUsersName;
+
+  private AtomicInteger uuidCounter;
+
+  @Before
+  public void setUpCounter() {
+    uuidCounter = new AtomicInteger();
+    migrator = new CommentJsonMigrator(new ChangeNoteJson(), "gerrit", allUsersName);
+  }
+
+  @Test
+  public void noOpIfAllCommentsAreJson() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Comment ps1Comment = newComment(notes, 1, "comment on ps1");
+    update.putComment(Status.PUBLISHED, ps1Comment);
+    update.commit();
+
+    notes = newNotes(c);
+    update = newUpdate(c, changeOwner);
+    Comment ps2Comment = newComment(notes, 2, "comment on ps2");
+    update.putComment(Status.PUBLISHED, ps2Comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString());
+
+    ChangeNotes oldNotes = notes;
+    checkMigrate(project, ImmutableList.of());
+    assertNoDifferences(notes, oldNotes);
+    assertThat(notes.getMetaId()).isEqualTo(oldNotes.getMetaId());
+  }
+
+  @Test
+  public void migratePublishedComments() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+
+    Comment ps1Comment1 = newComment(notes, 1, "first comment on ps1");
+    Comment ps2Comment1 = newComment(notes, 2, "first comment on ps2");
+    Comment ps1Comment2 = newComment(notes, 1, "second comment on ps1");
+
+    // Construct legacy format 'by hand'.
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ps1Comment1).build(), out1);
+
+    ByteArrayOutputStream out2 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(2, ps2Comment1).build(), out2);
+
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder()
+            .put(1, ps1Comment2)
+            .put(1, ps1Comment1)
+            .build(),
+        out3);
+
+    TestRepository<Repository> testRepository = new TestRepository<>(repo, rw);
+
+    String metaRefName = RefNames.changeMetaRef(c.getId());
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 1\n\nPatch-set: 1")
+        .add(ps1Comment1.revId, out1.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 2\n\nPatch-set: 2")
+        .add(ps2Comment1.revId, out2.toString())
+        .add(ps1Comment1.revId, out3.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment1.toString(),
+            getRevId(notes, 1), ps1Comment2.toString(),
+            getRevId(notes, 2), ps2Comment1.toString());
+
+    // Comments at each commit all have legacy format.
+    ImmutableList<RevCommit> oldLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertThat(oldLog).hasSize(4);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(2)))
+        .containsExactly(ps1Comment1.key, true);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(3)))
+        .containsExactly(ps1Comment1.key, true, ps1Comment2.key, true, ps2Comment1.key, true);
+
+    // Check that dryRun doesn't touch anything.
+    String refName = RefNames.changeMetaRef(c.getId());
+    ObjectId before = repo.getRefDatabase().getRef(refName).getObjectId();
+    ProjectMigrationResult dryRunResult = migrator.migrateProject(project, repo, true);
+    ObjectId after = repo.getRefDatabase().getRef(refName).getObjectId();
+    assertThat(before).isEqualTo(after);
+    assertThat(dryRunResult.refsUpdated).isEqualTo(ImmutableList.of(refName));
+
+    ChangeNotes oldNotes = notes;
+    checkMigrate(project, ImmutableList.of(refName));
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertNoDifferences(notes, oldNotes);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment1.toString(),
+            getRevId(notes, 1), ps1Comment2.toString(),
+            getRevId(notes, 2), ps2Comment1.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertLogEqualExceptTrees(newLog, oldLog);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(2)))
+        .containsExactly(ps1Comment1.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(3)))
+        .containsExactly(ps1Comment1.key, false, ps1Comment2.key, false, ps2Comment1.key, false);
+  }
+
+  @Test
+  public void migrateDraftComments() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+    ObjectId origMetaId = notes.getMetaId();
+
+    Comment ownerCommentPs1 = newComment(notes, 1, "owner comment on ps1", changeOwner);
+    Comment ownerCommentPs2 = newComment(notes, 2, "owner comment on ps2", changeOwner);
+    Comment otherCommentPs1 = newComment(notes, 1, "other user comment on ps1", otherUser);
+
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ownerCommentPs1).build(), out1);
+
+    ByteArrayOutputStream out2 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(2, ownerCommentPs2).build(), out2);
+
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, otherCommentPs1).build(), out3);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk allUsersRw = new RevWalk(allUsersRepo)) {
+      TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, allUsersRw);
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()))
+          .commit()
+          .message("Review ps 1\n\nPatch-set: 1")
+          .add(ownerCommentPs1.revId, out1.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()))
+          .commit()
+          .message("Review ps 1\n\nPatch-set: 2")
+          .add(ownerCommentPs2.revId, out2.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()))
+          .commit()
+          .message("Review ps 2\n\nPatch-set: 2")
+          .add(otherCommentPs1.revId, out3.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+    }
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getDraftComments(changeOwner.getAccountId())))
+        .containsExactly(
+            getRevId(notes, 1), ownerCommentPs1.toString(),
+            getRevId(notes, 2), ownerCommentPs2.toString());
+    assertThat(getToStringRepresentations(notes.getDraftComments(otherUser.getAccountId())))
+        .containsExactly(getRevId(notes, 1), otherCommentPs1.toString());
+
+    // Comments at each commit all have legacy format.
+    ImmutableList<RevCommit> oldOwnerLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()));
+    assertThat(oldOwnerLog).hasSize(2);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOwnerLog.get(0)))
+        .containsExactly(ownerCommentPs1.key, true);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOwnerLog.get(1)))
+        .containsExactly(ownerCommentPs1.key, true, ownerCommentPs2.key, true);
+
+    ImmutableList<RevCommit> oldOtherLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()));
+    assertThat(oldOtherLog).hasSize(1);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOtherLog.get(0)))
+        .containsExactly(otherCommentPs1.key, true);
+
+    ChangeNotes oldNotes = notes;
+    checkMigrate(
+        allUsers,
+        ImmutableList.of(
+            RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()),
+            RefNames.refsDraftComments(c.getId(), otherUser.getAccountId())));
+    assertNoDifferences(notes, oldNotes);
+
+    // Migration doesn't touch change ref.
+    assertThat(repo.exactRef(RefNames.changeMetaRef(c.getId())).getObjectId())
+        .isEqualTo(origMetaId);
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getDraftComments(changeOwner.getAccountId())))
+        .containsExactly(
+            getRevId(notes, 1), ownerCommentPs1.toString(),
+            getRevId(notes, 2), ownerCommentPs2.toString());
+    assertThat(getToStringRepresentations(notes.getDraftComments(otherUser.getAccountId())))
+        .containsExactly(getRevId(notes, 1), otherCommentPs1.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newOwnerLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()));
+    assertLogEqualExceptTrees(newOwnerLog, oldOwnerLog);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOwnerLog.get(0)))
+        .containsExactly(ownerCommentPs1.key, false);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOwnerLog.get(1)))
+        .containsExactly(ownerCommentPs1.key, false, ownerCommentPs2.key, false);
+
+    ImmutableList<RevCommit> newOtherLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()));
+    assertLogEqualExceptTrees(newOtherLog, oldOtherLog);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOtherLog.get(0)))
+        .containsExactly(otherCommentPs1.key, false);
+  }
+
+  @Test
+  public void migrateMixOfJsonAndLegacyComments() throws Exception {
+    // 3 comments: legacy, JSON, legacy. Because adding a comment necessarily rewrites the entire
+    // note, these comments need to be on separate patch sets.
+    Change c = newChange();
+    incrementPatchSet(c);
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+
+    Comment ps1Comment = newComment(notes, 1, "comment on ps1 (legacy)");
+
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ps1Comment).build(), out1);
+
+    TestRepository<Repository> testRepository = new TestRepository<>(repo, rw);
+
+    String metaRefName = RefNames.changeMetaRef(c.getId());
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 1\n\nPatch-set: 1")
+        .add(ps1Comment.revId, out1.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Comment ps2Comment = newComment(notes, 2, "comment on ps2 (JSON)");
+    update.putComment(Status.PUBLISHED, ps2Comment);
+    update.commit();
+
+    Comment ps3Comment = newComment(notes, 3, "comment on ps3 (legacy)");
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(3, ps3Comment).build(), out3);
+
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 3\n\nPatch-set: 3")
+        .add(ps3Comment.revId, out3.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString(),
+            getRevId(notes, 3), ps3Comment.toString());
+
+    // Comments at each commit match expected format.
+    ImmutableList<RevCommit> oldLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertThat(oldLog).hasSize(6);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(2))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(3)))
+        .containsExactly(ps1Comment.key, true);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(4)))
+        .containsExactly(ps1Comment.key, true, ps2Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(5)))
+        .containsExactly(ps1Comment.key, true, ps2Comment.key, false, ps3Comment.key, true);
+
+    ChangeNotes oldNotes = notes;
+    checkMigrate(project, ImmutableList.of(RefNames.changeMetaRef(c.getId())));
+    assertNoDifferences(notes, oldNotes);
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString(),
+            getRevId(notes, 3), ps3Comment.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertLogEqualExceptTrees(newLog, oldLog);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(2))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(3)))
+        .containsExactly(ps1Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(4)))
+        .containsExactly(ps1Comment.key, false, ps2Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(5)))
+        .containsExactly(ps1Comment.key, false, ps2Comment.key, false, ps3Comment.key, false);
+  }
+
+  private void checkMigrate(Project.NameKey project, List<String> expectedRefs) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      ProjectMigrationResult progress = migrator.migrateProject(project, repo, false);
+
+      assertThat(progress.ok).isTrue();
+      assertThat(progress.refsUpdated).isEqualTo(expectedRefs);
+    }
+  }
+
+  private Comment newComment(ChangeNotes notes, int psNum, String message) {
+    return newComment(notes, psNum, message, changeOwner);
+  }
+
+  private Comment newComment(
+      ChangeNotes notes, int psNum, String message, IdentifiedUser commenter) {
+    return newComment(
+        new PatchSet.Id(notes.getChangeId(), psNum),
+        "filename",
+        "uuid-" + uuidCounter.getAndIncrement(),
+        null,
+        0,
+        commenter,
+        null,
+        TimeUtil.nowTs(),
+        message,
+        (short) 1,
+        getRevId(notes, psNum).get(),
+        false);
+  }
+
+  private void incrementPatchSet(Change c) throws Exception {
+    TestChanges.incrementPatchSet(c);
+    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.commit();
+  }
+
+  private static RevId getRevId(ChangeNotes notes, int psNum) {
+    PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), psNum);
+    PatchSet ps = notes.getPatchSets().get(psId);
+    checkArgument(ps != null, "no patch set %s: %s", psNum, notes.getPatchSets());
+    return ps.getRevision();
+  }
+
+  private static ListMultimap<RevId, String> getToStringRepresentations(
+      ListMultimap<RevId, Comment> comments) {
+    // Use string representation for equality comparison in this test, because Comment#equals only
+    // compares keys.
+    return Multimaps.transformValues(comments, Comment::toString);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMapForPublishedComments(
+      ChangeNotes notes, ObjectId metaId) throws Exception {
+    return getLegacyFormatMap(project, notes.getChangeId(), metaId, Status.PUBLISHED);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMapForDraftComments(
+      ChangeNotes notes, ObjectId metaId) throws Exception {
+    return getLegacyFormatMap(allUsers, notes.getChangeId(), metaId, Status.DRAFT);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMap(
+      Project.NameKey project, Change.Id changeId, ObjectId metaId, Status status)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader)) {
+      NoteMap noteMap = NoteMap.read(reader, rw.parseCommit(metaId));
+      RevisionNoteMap<ChangeRevisionNote> revNoteMap =
+          RevisionNoteMap.parse(
+              noteUtil.getChangeNoteJson(),
+              noteUtil.getLegacyChangeNoteRead(),
+              changeId,
+              reader,
+              noteMap,
+              status);
+      return revNoteMap
+          .revisionNotes
+          .values()
+          .stream()
+          .flatMap(crn -> crn.getComments().stream())
+          .collect(toImmutableMap(c -> c.key, c -> c.legacyFormat));
+    }
+  }
+
+  private ImmutableList<RevCommit> log(Project.NameKey project, String refName) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+      Ref ref = repo.exactRef(refName);
+      checkArgument(ref != null, "missing ref: %s", refName);
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      return ImmutableList.copyOf(rw);
+    }
+  }
+
+  private static void assertLogEqualExceptTrees(
+      ImmutableList<RevCommit> actualLog, ImmutableList<RevCommit> expectedLog) {
+    assertThat(actualLog).hasSize(expectedLog.size());
+    for (int i = 0; i < expectedLog.size(); i++) {
+      RevCommit actual = actualLog.get(i);
+      RevCommit expected = expectedLog.get(i);
+      assertThat(actual.getAuthorIdent())
+          .named("author of entry %s", i)
+          .isEqualTo(expected.getAuthorIdent());
+      assertThat(actual.getCommitterIdent())
+          .named("committer of entry %s", i)
+          .isEqualTo(expected.getCommitterIdent());
+      assertThat(actual.getFullMessage()).named("message of entry %s", i).isNotNull();
+      assertThat(actual.getFullMessage())
+          .named("message of entry %s", i)
+          .isEqualTo(expected.getFullMessage());
+    }
+  }
+
+  private void assertNoDifferences(ChangeNotes actual, ChangeNotes expected) throws Exception {
+    assertThat(
+            ChangeBundle.fromNotes(commentsUtil, actual)
+                .differencesFrom(ChangeBundle.fromNotes(commentsUtil, expected)))
+        .isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index f826fec..8daf67f 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -20,11 +20,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestChanges;
 import java.util.Date;
@@ -62,14 +62,14 @@
             + "Commit: "
             + update.getCommit().name()
             + "\n"
-            + "Reviewer: Change Owner <1@gerrit>\n"
-            + "CC: Other Account <2@gerrit>\n"
+            + "Reviewer: Gerrit User 1 <1@gerrit>\n"
+            + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
             + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
     assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
@@ -151,7 +151,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId,
         ImmutableList.of(
@@ -177,15 +177,15 @@
             + submissionId.toStringForStorage()
             + "\n"
             + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n"
             + "Submitted-with: NEED: Code-Review\n"
             + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n"
             + "Submitted-with: NEED: Alternative-Code-Review\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
     assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
@@ -210,7 +210,7 @@
     assertBodyEquals("Update patch set 1\n\nComment on the change.\n\nPatch-set: 1\n", commit);
 
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("GerritAccount #3");
+    assertThat(author.getName()).isEqualTo("Gerrit User 3");
     assertThat(author.getEmailAddress()).isEqualTo("3@gerrit");
   }
 
@@ -220,7 +220,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
@@ -245,7 +245,7 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Change Owner <1@gerrit>\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
         update.getResult());
   }
 
@@ -360,7 +360,7 @@
 
     RevCommit commit = parseCommit(update.getResult());
     PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getName()).isEqualTo("Gerrit User 2");
     assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
 
     assertBodyEquals(
@@ -369,7 +369,7 @@
             + "Message on behalf of other user\n"
             + "\n"
             + "Patch-set: 1\n"
-            + "Real-user: Change Owner <1@gerrit>\n",
+            + "Real-user: Gerrit User 1 <1@gerrit>\n",
         commit);
   }
 
@@ -424,4 +424,8 @@
     RevCommit commit = parseCommit(commitId);
     assertThat(commit.getFullMessage()).isEqualTo(expected);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 7890de8..31eee9f 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -47,7 +47,6 @@
 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.IdentifiedUser;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -56,6 +55,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -203,7 +203,7 @@
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
   @Inject private DefaultRefFilter.Factory refFilterFactory;
-  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject private TransferConfig transferConfig;
 
   @Before
   public void setUp() throws Exception {
@@ -971,6 +971,7 @@
             repoManager,
             commentLinks,
             capabilityCollectionFactory,
+            transferConfig,
             pc));
     return repo;
   }
@@ -988,7 +989,6 @@
         changeControlFactory,
         permissionBackend,
         refFilterFactory,
-        identifiedUserFactory,
         new MockUser(name, memberOf),
         newProjectState(local));
   }
@@ -998,7 +998,7 @@
     return all.get(local.getProject().getNameKey());
   }
 
-  private class MockUser extends CurrentUser {
+  private static class MockUser extends CurrentUser {
     @Nullable private final String username;
     private final GroupMembership groups;
 
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f6d2568..9d01405 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -71,6 +71,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -449,6 +450,18 @@
   }
 
   @Test
+  public void byDeletedAccount() throws Exception {
+    AccountInfo user = newAccountWithFullName("jdoe", "John Doe");
+    Account.Id userId = Account.Id.tryParse(user._accountId.toString()).get();
+    assertQuery("John", user);
+
+    for (AccountIndex index : indexes.getWriteIndexes()) {
+      index.delete(userId);
+    }
+    assertQuery("John");
+  }
+
+  @Test
   public void withLimit() throws Exception {
     String domain = name("test.com");
     AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
@@ -528,17 +541,19 @@
 
   @Test
   public void withSecondaryEmailsWithoutModifyAccountCapability() throws Exception {
-    AccountInfo user = newAccount("myuser", "My User", "abc@example.com", true);
+    AccountInfo user = newAccount("myuser", "My User", "other@example.com", true);
+
+    AccountInfo otherUser = newAccount("otheruser", "Other User", "abc@example.com", true);
     String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
-    addEmails(user, secondaryEmails);
+    addEmails(otherUser, secondaryEmails);
 
     requestContext.setContext(newRequestContext(new Account.Id(user._accountId)));
 
-    List<AccountInfo> result = newQuery(user.username).withSuggest(true).get();
+    List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
 
     exception.expect(AuthException.class);
-    newQuery(user.username).withOption(ListAccountsOption.ALL_EMAILS).get();
+    newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get();
   }
 
   @Test
@@ -564,7 +579,7 @@
       PersonIdent ident = serverIdent.get();
       md.getCommitBuilder().setAuthor(ident);
       md.getCommitBuilder().setCommitter(ident);
-      new AccountConfig(accountId, repo)
+      new AccountConfig(accountId, allUsers, repo)
           .load()
           .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
           .commit(md);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 95f2df3..82c9147 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -799,6 +799,43 @@
   }
 
   @Test
+  public void byRepository() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repository:foo");
+    assertQuery("repository:repo");
+    assertQuery("repository:repo1", change1);
+    assertQuery("repository:repo2", change2);
+  }
+
+  @Test
+  public void byParentRepository() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("parentrepository:repo1", change2, change1);
+    assertQuery("parentrepository:repo2", change2);
+  }
+
+  @Test
+  public void byRepositoryPrefix() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("repositories:foo");
+    assertQuery("repositories:repo1", change1);
+    assertQuery("repositories:repo2", change2);
+    assertQuery("repositories:repo", change2, change1);
+  }
+
+  @Test
   public void byBranchAndRef() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeForBranch(repo, "master"));
@@ -831,7 +868,13 @@
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
     Change change4 = insert(repo, ins4);
 
-    Change change5 = insert(repo, newChange(repo));
+    ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
+    Change change5 = insert(repo, ins5);
+
+    ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
+    Change change6 = insert(repo, ins6);
+
+    Change change_no_topic = insert(repo, newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -839,8 +882,9 @@
     assertQuery("topic:feature2", change2);
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
-    assertQuery("topic:\"\"", change5);
-    assertQuery("intopic:\"\"", change5);
+    assertQuery("intopic:gerrit", change6, change5);
+    assertQuery("topic:\"\"", change_no_topic);
+    assertQuery("intopic:\"\"", change_no_topic);
   }
 
   @Test
@@ -887,6 +931,26 @@
   }
 
   @Test
+  public void byMessageMixedCase() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("message:gerrit", change2, change1);
+    assertQuery("message:Gerrit", change2, change1);
+  }
+
+  @Test
+  public void byMessageSubstring() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    assertQuery("message:gerrit", change1);
+  }
+
+  @Test
   public void byLabel() throws Exception {
     accountManager.authenticate(AuthRequest.forUser("anotheruser"));
     TestRepository<Repo> repo = createProject("repo");
@@ -2921,6 +2985,13 @@
     assertQuery(query);
   }
 
+  @Test
+  public void byUrlEncodedProject() throws Exception {
+    TestRepository<Repo> repo = createProject("repo+foo");
+    Change change = insert(repo, newChange(repo));
+    assertQuery("project:repo+foo", change);
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index b87bbf7..de2acf0 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -18,7 +18,7 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -68,11 +68,11 @@
         .isEqualTo(
             ConflictKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setOtherCommit(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index bacbb60..750813a 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -104,6 +105,8 @@
 
   @Inject protected GroupIndexCollection indexes;
 
+  @Inject private GroupIndexCollection groupIndexes;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
   protected ReviewDb db;
@@ -392,6 +395,19 @@
     assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
   }
 
+  @Test
+  public void byDeletedGroup() throws Exception {
+    GroupInfo group = createGroup(name("group"));
+    AccountGroup.UUID uuid = new AccountGroup.UUID(group.id);
+    String query = "uuid:" + uuid;
+    assertQuery(query, group);
+
+    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+      index.delete(uuid);
+    }
+    assertQuery(query);
+  }
+
   private Account.Id createAccountOutsideRequestContext(
       String username, String fullName, String email, boolean active) throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index e34746c..8790c64 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -15,15 +15,26 @@
 package com.google.gerrit.server.query.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.Projects.QueryRequest;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
@@ -38,7 +49,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -83,7 +94,7 @@
 
   @Inject protected OneOffRequestContext oneOffRequestContext;
 
-  @Inject protected InternalAccountQuery internalAccountQuery;
+  @Inject protected ProjectIndexCollection indexes;
 
   @Inject protected AllProjectsName allProjects;
 
@@ -211,6 +222,30 @@
   }
 
   @Test
+  public void byState() throws Exception {
+    assume().that(getSchemaVersion() >= 2).isTrue();
+
+    ProjectInfo project1 = createProjectWithState(name("project1"), ProjectState.ACTIVE);
+    ProjectInfo project2 = createProjectWithState(name("project2"), ProjectState.READ_ONLY);
+    assertQuery("state:active", project1);
+    assertQuery("state:read-only", project2);
+  }
+
+  @Test
+  public void byState_emptyQuery() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("state operator requires a value");
+    assertQuery("state:\"\"");
+  }
+
+  @Test
+  public void byState_badQuery() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("state operator must be either 'active' or 'read-only'");
+    assertQuery("state:bla");
+  }
+
+  @Test
   public void byDefaultField() throws Exception {
     ProjectInfo project1 = createProject(name("foo-project"));
     ProjectInfo project2 = createProject(name("project2"));
@@ -252,7 +287,7 @@
 
   @Test
   public void asAnonymous() throws Exception {
-    ProjectInfo project = createProject(name("project"));
+    ProjectInfo project = createProjectRestrictedToRegisteredUsers(name("project"));
 
     setAnonymous();
     assertQuery("name:" + project.name);
@@ -291,6 +326,29 @@
     return gApi.projects().create(in).get();
   }
 
+  protected ProjectInfo createProjectWithState(String name, ProjectState state) throws Exception {
+    ProjectInfo info = createProject(name);
+    ConfigInput config = new ConfigInput();
+    config.state = state;
+    gApi.projects().name(info.name).config(config);
+    return info;
+  }
+
+  protected ProjectInfo createProjectRestrictedToRegisteredUsers(String name) throws Exception {
+    createProject(name);
+
+    ProjectAccessInput accessInput = new ProjectAccessInput();
+    AccessSectionInfo accessSection = new AccessSectionInfo();
+    PermissionInfo read = new PermissionInfo(null, null);
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+    read.rules = ImmutableMap.of(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions = ImmutableMap.of("read", read);
+    accessInput.add = ImmutableMap.of("refs/*", accessSection);
+    gApi.projects().name(name).access(accessInput);
+
+    return gApi.projects().name(name).get();
+  }
+
   protected ProjectInfo getProject(Project.NameKey nameKey) throws Exception {
     return gApi.projects().name(nameKey.get()).get();
   }
@@ -354,6 +412,14 @@
     return b.toString();
   }
 
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<ProjectData> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
+
   protected static Iterable<String> names(ProjectInfo... projects) {
     return names(Arrays.asList(projects));
   }
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index eaa3df3..f0c455e 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -9,6 +9,8 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 42452df..8f4c90d 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,9 +7,11 @@
     resources = ["//prologtests:gerrit_common_test"],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 314941e..086dd65 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -49,7 +49,7 @@
             bind(PrologEnvironment.Args.class)
                 .toInstance(
                     new PrologEnvironment.Args(
-                        null, null, null, null, null, null, null, cfg, null));
+                        null, null, null, null, null, null, null, cfg, null, null));
           }
         });
   }
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
new file mode 100644
index 0000000..27f4423
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2018 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.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.reviewdb.client.Account;
+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.PatchSetApproval;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class IgnoreSelfApprovalRuleTest {
+  private static final Change.Id CHANGE_ID = new Change.Id(100);
+  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final Account.Id USER1 = makeAccount(100001);
+
+  @Test
+  public void filtersByLabel() {
+    LabelType codeReview = makeLabel("Code-Review");
+    PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
+    PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterApprovalsByLabel(
+            ImmutableList.of(approvalVerified, approvalCr), VERIFIED);
+
+    assertThat(filteredApprovals).containsExactly(approvalVerified);
+  }
+
+  @Test
+  public void filtersVotesFromUser() {
+    PatchSetApproval approvalM2 = makeApproval(VERIFIED.getLabelId(), USER1, -2);
+    PatchSetApproval approvalM1 = makeApproval(VERIFIED.getLabelId(), USER1, -1);
+
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(
+            approvalM2,
+            approvalM1,
+            makeApproval(VERIFIED.getLabelId(), USER1, 0),
+            makeApproval(VERIFIED.getLabelId(), USER1, +1),
+            makeApproval(VERIFIED.getLabelId(), USER1, +2));
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterOutPositiveApprovalsOfUser(approvals, USER1);
+
+    assertThat(filteredApprovals).containsExactly(approvalM1, approvalM2);
+  }
+
+  private static LabelType makeLabel(String labelName) {
+    List<LabelValue> values = new ArrayList<>();
+    // The label text is irrelevant here, only the numerical value is used
+    values.add(new LabelValue((short) -2, "-2"));
+    values.add(new LabelValue((short) -1, "-1"));
+    values.add(new LabelValue((short) 0, "No vote."));
+    values.add(new LabelValue((short) 1, "+1"));
+    values.add(new LabelValue((short) 2, "+2"));
+    return new LabelType(labelName, values);
+  }
+
+  private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
+    PatchSetApproval.Key key = makeKey(PS_ID, accountId, labelId);
+    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
+  }
+
+  private static PatchSetApproval.Key makeKey(
+      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
+    return new PatchSetApproval.Key(psId, accountId, labelId);
+  }
+
+  private static Account.Id makeAccount(int account) {
+    return new Account.Id(account);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
new file mode 100644
index 0000000..8622b32
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2018 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.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class PrologRuleEvaluatorTest {
+
+  @Test
+  public void validLabelNamesAreKept() {
+    for (String labelName : new String[] {"Verified", "Code-Review"}) {
+      assertThat(PrologRuleEvaluator.checkLabelName(labelName)).isEqualTo(labelName);
+    }
+  }
+
+  @Test
+  public void labelWithSpacesIsTransformed() {
+    assertThat(PrologRuleEvaluator.checkLabelName("Label with spaces"))
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-Labelwithspaces");
+  }
+
+  @Test
+  public void labelStartingWithADashIsTransformed() {
+    assertThat(PrologRuleEvaluator.checkLabelName("-dashed-label"))
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-dashed-label");
+  }
+
+  @Test
+  public void labelWithInvalidCharactersIsTransformed() {
+    assertThat(PrologRuleEvaluator.checkLabelName("*urgent*"))
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-urgent");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
index a6178ac..c76a246 100644
--- a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
+++ b/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
@@ -66,6 +66,7 @@
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
 
   private AtomicInteger idCounter;
+  private AllUsersName allUsersName;
   private Repository repo;
   private GroupRebuilder rebuilder;
   private GroupBundle.Factory bundleFactory;
@@ -74,7 +75,7 @@
   public void setUp() throws Exception {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
     idCounter = new AtomicInteger();
-    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repo = new InMemoryRepositoryManager().createRepository(allUsersName);
     rebuilder =
         new GroupRebuilder(
@@ -83,7 +84,7 @@
             // Note that the expected name/email values in tests are not necessarily realistic,
             // since they use these trivial name/email functions.
             getAuditLogFormatter());
-    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID));
+    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID, allUsersName));
   }
 
   @After
@@ -542,6 +543,8 @@
   public void combineWithBatchGroupNameNotes() throws Exception {
     AccountGroup g1 = newGroup("a");
     AccountGroup g2 = newGroup("b");
+    GroupReference gr1 = new GroupReference(g1.getGroupUUID(), g1.getName());
+    GroupReference gr2 = new GroupReference(g2.getGroupUUID(), g2.getName());
 
     GroupBundle b1 = builder().group(g1).build();
     GroupBundle b2 = builder().group(g2).build();
@@ -551,8 +554,7 @@
     rebuilder.rebuild(repo, b1, bru);
     rebuilder.rebuild(repo, b2, bru);
     try (ObjectInserter inserter = repo.newObjectInserter()) {
-      ImmutableList<GroupReference> refs =
-          ImmutableList.of(GroupReference.forGroup(g1), GroupReference.forGroup(g2));
+      ImmutableList<GroupReference> refs = ImmutableList.of(gr1, gr2);
       GroupNameNotes.updateAllGroups(repo, inserter, bru, refs, newPersonIdent());
       inserter.flush();
     }
@@ -569,9 +571,7 @@
     assertMigratedCleanly(reload(g1), b1);
     assertMigratedCleanly(reload(g2), b2);
 
-    GroupReference group1 = GroupReference.forGroup(g1);
-    GroupReference group2 = GroupReference.forGroup(g2);
-    assertThat(GroupNameNotes.loadAllGroups(repo)).containsExactly(group1, group2);
+    assertThat(GroupNameNotes.loadAllGroups(repo)).containsExactly(gr1, gr2);
   }
 
   @Test
@@ -607,7 +607,7 @@
   }
 
   private GroupBundle reload(AccountGroup g) throws Exception {
-    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
+    return bundleFactory.fromNoteDb(allUsersName, repo, g.getGroupUUID());
   }
 
   private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
index 7f8b6f3..d3f69982 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
@@ -36,6 +36,7 @@
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
@@ -108,15 +109,22 @@
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
     assertThat(codeReview.isCopyMinScore()).isTrue();
-    assertValueRange(codeReview, 2, 1, 0, -1, -2);
+    assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
   private void assertValueRange(LabelType label, Integer... range) {
-    assertThat(label.getValuesAsList()).containsExactlyElementsIn(Arrays.asList(range)).inOrder();
-    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
-    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
+    List<Integer> rangeList = Arrays.asList(range);
+    assertThat(rangeList).isNotEmpty();
+    assertThat(rangeList).isStrictlyOrdered();
+
+    assertThat(label.getValues().stream().map(v -> (int) v.getValue()))
+        .containsExactlyElementsIn(rangeList)
+        .inOrder();
+    assertThat(label.getMax().getValue()).isEqualTo(Collections.max(rangeList));
+    assertThat(label.getMin().getValue()).isEqualTo(Collections.min(rangeList));
     for (LabelValue v : label.getValues()) {
-      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
+      assertThat(v.getText()).isNotNull();
+      assertThat(v.getText()).isNotEmpty();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index ed94c97..c4844b1 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -94,7 +95,11 @@
                     Config cfg = new Config();
                     cfg.setString("user", null, "name", "Gerrit Code Review");
                     cfg.setString("user", null, "email", "gerrit@localhost");
-
+                    cfg.setString(
+                        GerritServerIdProvider.SECTION,
+                        null,
+                        GerritServerIdProvider.KEY,
+                        "1234567");
                     bind(Config.class) //
                         .annotatedWith(GerritServerConfig.class) //
                         .toInstance(cfg);
diff --git a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
index a01d611..0080f3f 100644
--- a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
+++ b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
@@ -189,7 +189,7 @@
       Supplier<VersionedAccountPreferences> prefsSupplier) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       VersionedAccountPreferences prefs = prefsSupplier.get();
-      prefs.load(repo);
+      prefs.load(allUsersName, repo);
       Config cfg = prefs.getConfig();
       return cfg.getSubsections(MY)
           .stream()
diff --git a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
index 6020325..75f9307 100644
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
+++ b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
@@ -586,7 +586,7 @@
     AccountGroup group = createInReviewDb("group");
 
     TestGroupBackend testGroupBackend = new TestGroupBackend();
-    backends.add(testGroupBackend);
+    backends.add("gerrit", testGroupBackend);
     AccountGroup.UUID subgroupUuid = testGroupBackend.create("test").getGroupUUID();
     assertThat(groupBackend.handles(subgroupUuid)).isTrue();
     addSubgroupsInReviewDb(group.getId(), subgroupUuid);
@@ -916,7 +916,7 @@
 
   private GroupBundle readGroupBundleFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
     try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return groupBundleFactory.fromNoteDb(allUsersRepo, groupUuid);
+      return groupBundleFactory.fromNoteDb(allUsersName, allUsersRepo, groupUuid);
     }
   }
 
@@ -985,7 +985,7 @@
 
   private Optional<InternalGroup> getGroupFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
     try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupConfig.loadForGroup(allUsersRepo, groupUuid).getLoadedGroup();
+      return GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid).getLoadedGroup();
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java b/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
deleted file mode 100644
index bd54ddc..0000000
--- a/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
+++ /dev/null
@@ -1,666 +0,0 @@
-// Copyright (C) 2009 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.tools.hooks;
-
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.TruthJUnit.assume;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.server.util.HostPlatform;
-import java.io.File;
-import java.io.IOException;
-import java.util.Date;
-import java.util.TimeZone;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class CommitMsgHookTest extends HookTestCase {
-  private final String SOB1 = "Signed-off-by: J Author <ja@example.com>\n";
-  private final String SOB2 = "Signed-off-by: J Committer <jc@example.com>\n";
-
-  @BeforeClass
-  public static void skipIfWin32Platform() {
-    assume().that(HostPlatform.isWin32()).isFalse();
-  }
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-    final Date when = author.getWhen();
-    final TimeZone tz = author.getTimeZone();
-
-    author = new PersonIdent("J. Author", "ja@example.com");
-    author = new PersonIdent(author, when, tz);
-
-    committer = new PersonIdent("J. Committer", "jc@example.com");
-    committer = new PersonIdent(committer, when, tz);
-  }
-
-  @Test
-  public void emptyMessages() throws Exception {
-    // Empty input must yield empty output so commit will abort.
-    // Note we must consider different commit templates formats.
-    //
-    hookDoesNotModify("");
-    hookDoesNotModify(" ");
-    hookDoesNotModify("\n");
-    hookDoesNotModify("\n\n");
-    hookDoesNotModify("  \n  ");
-
-    hookDoesNotModify("#");
-    hookDoesNotModify("#\n");
-    hookDoesNotModify("# on branch master\n# Untracked files:\n");
-    hookDoesNotModify("\n# on branch master\n# Untracked files:\n");
-    hookDoesNotModify("\n\n# on branch master\n# Untracked files:\n");
-
-    hookDoesNotModify(
-        "\n# on branch master\ndiff --git a/src b/src\n"
-            + "new file mode 100644\nindex 0000000..c78b7f0\n");
-  }
-
-  @Test
-  public void changeIdAlreadySet() throws Exception {
-    // If a Change-Id is already present in the footer, the hook must
-    // not modify the message but instead must leave the identity alone.
-    //
-    hookDoesNotModify(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Iaeac9b4149291060228ef0154db2985a31111335\n");
-    hookDoesNotModify(
-        "fix: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I388bdaf52ed05b55e62a22d0a20d2c1ae0d33e7e\n");
-    hookDoesNotModify(
-        "fix-a-widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id3bc5359d768a6400450283e12bdfb6cd135ea4b\n");
-    hookDoesNotModify(
-        "FIX: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1b55098b5a2cce0b3f3da783dda50d5f79f873fa\n");
-    hookDoesNotModify(
-        "Fix-A-Widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I4f4e2e1e8568ddc1509baecb8c1270a1fb4b6da7\n");
-  }
-
-  @Test
-  public void timeAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    tick();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3251906b99dda598a58a6346d8126237ee1ea800\n", //
-        call("a\n"));
-
-    tick();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I69adf9208d828f41a3d7e41afbca63aff37c0c5c\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void firstParentAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    setHEAD();
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I51e86482bde7f92028541aaf724d3a3f996e7ea2\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void dirCacheAltersId() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    final DirCacheBuilder builder = repository.lockDirCache().builder();
-    builder.add(file("A"));
-    assertTrue(builder.commit());
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: If56597ea9759f23b070677ea6f064c60c38da631\n", //
-        call("a\n"));
-  }
-
-  @Test
-  public void singleLineMessages() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
-        call("a\n"));
-
-    assertEquals(
-        "fix: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I0f13d0e6c739ca3ae399a05a93792e80feb97f37\n", //
-        call("fix: this thing\n"));
-    assertEquals(
-        "fix-a-widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1a1a0c751e4273d532e4046a501a612b9b8a775e\n", //
-        call("fix-a-widget: this thing\n"));
-
-    assertEquals(
-        "FIX: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: If816d944c57d3893b60cf10c65931fead1290d97\n", //
-        call("FIX: this thing\n"));
-    assertEquals(
-        "Fix-A-Widget: this thing\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3e18d00cbda2ba1f73aeb63ed8c7d57d7fd16c76\n", //
-        call("Fix-A-Widget: this thing\n"));
-  }
-
-  @Test
-  public void multiLineMessagesWithoutFooter() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id0b4f42d3d6fc1569595c9b97cb665e738486f5d\n", //
-        call("a\n\nb\n"));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7d237b20058a0f46cc3f5fabc4a0476877289d75\n", //
-        call("a\n\nb\nc\nd\ne\n"));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n", //
-        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n"));
-  }
-
-  @Test
-  public void singleLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1, //
-        call("a\n\n" + SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call("a\n\n" + SOB1 + SOB2));
-  }
-
-  @Test
-  public void multiLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
-            + //
-            SOB1, //
-        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n\n" + SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "b\nc\nd\ne\n"
-                + //
-                "\n"
-                + //
-                "f\ng\nh\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "b: not a footer\nc\nd\ne\n"
-            + //
-            "\n"
-            + //
-            "f\ng\nh\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I8869aabd44b3017cd55d2d7e0d546a03e3931ee2\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "b: not a footer\nc\nd\ne\n"
-                + //
-                "\n"
-                + //
-                "f\ng\nh\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2));
-  }
-
-  @Test
-  public void noteInMiddle() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "NOTE: This\n"
-            + //
-            "does not fix it.\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I988a127969a6ee5e58db546aab74fc46e66847f8\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "NOTE: This\n"
-                + //
-                "does not fix it.\n"));
-  }
-
-  @Test
-  public void kernelStyleFooter() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I1bd787f9e7590a2ac82b02c404c955ffb21877c4\n"
-            + //
-            SOB1
-            + //
-            "[ja: Fixed\n"
-            + //
-            "     the indentation]\n"
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                "[ja: Fixed\n"
-                + //
-                "     the indentation]\n"
-                + //
-                SOB2));
-  }
-
-  @Test
-  public void changeIdAfterBugOrIssue() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Bug: 42\n"
-            + //
-            "Change-Id: I8c0321227c4324e670b9ae8cf40eccc87af21b1b\n"
-            + //
-            SOB1, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "Bug: 42\n"
-                + //
-                SOB1));
-
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Issue: 42\n"
-            + //
-            "Change-Id: Ie66e07d89ae5b114c0975b49cf326e90331dd822\n"
-            + //
-            SOB1, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "Issue: 42\n"
-                + //
-                SOB1));
-  }
-
-  @Test
-  public void commitDashV() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
-            + //
-            SOB1
-            + //
-            SOB2, //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                SOB1
-                + //
-                SOB2
-                + //
-                "\n"
-                + //
-                "# on branch master\n"
-                + //
-                "diff --git a/src b/src\n"
-                + //
-                "new file mode 100644\n"
-                + //
-                "index 0000000..c78b7f0\n"));
-  }
-
-  @Test
-  public void withEndingURL() throws Exception {
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "http://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I3b7e4e16b503ce00f07ba6ad01d97a356dad7701\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "http://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "https://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I62b9039e2fc0dce274af55e8f99312a8a80a805d\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "https://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "ftp://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I71b05dc1f6b9a5540a53a693e64d58b65a8910e8\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "ftp://example.com/ fixes this\n"));
-    assertEquals(
-        "a\n"
-            + //
-            "\n"
-            + //
-            "git://example.com/ fixes this\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: Id34e942baa68d790633737d815ddf11bac9183e5\n", //
-        call(
-            "a\n"
-                + //
-                "\n"
-                + //
-                "git://example.com/ fixes this\n"));
-  }
-
-  @Test
-  public void withFalseTags() throws Exception {
-    assertEquals(
-        "foo\n"
-            + //
-            "\n"
-            + //
-            "FakeLine:\n"
-            + //
-            "  foo\n"
-            + //
-            "  bar\n"
-            + //
-            "\n"
-            + //
-            "Change-Id: I67632a37fd2e08a35f766f52fc9a47f4ea868c55\n"
-            + //
-            "RealTag: abc\n", //
-        call(
-            "foo\n"
-                + //
-                "\n"
-                + //
-                "FakeLine:\n"
-                + //
-                "  foo\n"
-                + //
-                "  bar\n"
-                + //
-                "\n"
-                + //
-                "RealTag: abc\n"));
-  }
-
-  private void hookDoesNotModify(String in) throws Exception {
-    assertEquals(in, call(in));
-  }
-
-  private String call(String body) throws Exception {
-    final File tmp = write(body);
-    try {
-      final File hook = getHook("commit-msg");
-      assertEquals(0, runHook(repository, hook, tmp.getAbsolutePath()));
-      return read(tmp);
-    } finally {
-      tmp.delete();
-    }
-  }
-
-  private DirCacheEntry file(String name) throws IOException {
-    try (ObjectInserter oi = repository.newObjectInserter()) {
-      final DirCacheEntry e = new DirCacheEntry(name);
-      e.setFileMode(FileMode.REGULAR_FILE);
-      e.setObjectId(oi.insert(Constants.OBJ_BLOB, Constants.encode(name)));
-      oi.flush();
-      return e;
-    }
-  }
-
-  private void setHEAD() throws Exception {
-    try (ObjectInserter oi = repository.newObjectInserter()) {
-      final CommitBuilder commit = new CommitBuilder();
-      commit.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
-      commit.setAuthor(author);
-      commit.setCommitter(committer);
-      commit.setMessage("test\n");
-      ObjectId commitId = oi.insert(commit);
-
-      final RefUpdate ref = repository.updateRef(Constants.HEAD);
-      ref.setNewObjectId(commitId);
-      Result result = ref.forceUpdate();
-      assertWithMessage(Constants.HEAD + " did not change: " + ref.getResult())
-          .that(result)
-          .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java b/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
deleted file mode 100644
index ac1ce53..0000000
--- a/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2009 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.
-//
-// Portions related to finding the hook script to execute are:
-// Copyright (C) 2008, Imran M Yousuf <imyousuf@smartitengineering.com>
-//
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or
-// without modification, are permitted provided that the following
-// conditions are met:
-//
-// - Redistributions of source code must retain the above copyright
-// notice, this list of conditions and the following disclaimer.
-//
-// - Redistributions in binary form must reproduce the above
-// copyright notice, this list of conditions and the following
-// disclaimer in the documentation and/or other materials provided
-// with the distribution.
-//
-// - Neither the name of the Git Development Community nor the
-// names of its contributors may be used to endorse or promote
-// products derived from this software without specific prior
-// written permission.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
-// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
-// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-// OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
-// NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
-// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
-// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-package com.google.gerrit.server.tools.hooks;
-
-import static com.google.common.truth.Truth.assertWithMessage;
-
-import com.google.common.io.ByteStreams;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.URL;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Ignore;
-
-@Ignore
-public abstract class HookTestCase extends LocalDiskRepositoryTestCase {
-  protected Repository repository;
-  private final Map<String, File> hooks = new TreeMap<>();
-  private final List<File> cleanup = new ArrayList<>();
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-    repository = createWorkRepository();
-  }
-
-  @Override
-  @After
-  public void tearDown() throws Exception {
-    super.tearDown();
-    for (File p : cleanup) {
-      if (!p.delete()) {
-        p.deleteOnExit();
-      }
-    }
-    cleanup.clear();
-  }
-
-  protected File getHook(String name) throws IOException {
-    File hook = hooks.get(name);
-    if (hook != null) {
-      return hook;
-    }
-
-    String scproot = "com/google/gerrit/server/tools/root";
-    String path = scproot + "/hooks/" + name;
-    String errorMessage = "Cannot locate " + path + " in CLASSPATH";
-    URL url = cl().getResource(path);
-    assertWithMessage(errorMessage).that(url).isNotNull();
-
-    String protocol = url.getProtocol();
-    assertWithMessage("Cannot invoke " + url).that(protocol).isAnyOf("file", "jar");
-
-    if ("file".equals(protocol)) {
-      hook = new File(url.getPath());
-      assertWithMessage(errorMessage).that(hook.isFile()).isTrue();
-      long time = hook.lastModified();
-      hook.setExecutable(true);
-      hook.setLastModified(time);
-      hooks.put(name, hook);
-    } else if ("jar".equals(protocol)) {
-      try (InputStream in = url.openStream()) {
-        hook = File.createTempFile("hook_", ".sh");
-        cleanup.add(hook);
-        try (OutputStream out = Files.newOutputStream(hook.toPath())) {
-          ByteStreams.copy(in, out);
-        }
-      }
-      hook.setExecutable(true);
-      hooks.put(name, hook);
-    }
-    return hook;
-  }
-
-  private ClassLoader cl() {
-    return HookTestCase.class.getClassLoader();
-  }
-}
diff --git a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
index 39afcac..808eca8 100644
--- a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
+++ b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
@@ -34,11 +34,4 @@
     assertEquals(0xdeadbeef, IdGenerator.unmix(IdGenerator.mix(0xdeadbeef)));
     assertEquals(0x0b966b11, IdGenerator.unmix(IdGenerator.mix(0x0b966b11)));
   }
-
-  @Test
-  public void format() {
-    assertEquals("0000000f", IdGenerator.format(0xf));
-    assertEquals("801234ab", IdGenerator.format(0x801234ab));
-    assertEquals("deadbeef", IdGenerator.format(0xdeadbeef));
-  }
 }
diff --git a/javatests/com/google/gerrit/server/util/ParboiledTest.java b/javatests/com/google/gerrit/server/util/ParboiledTest.java
deleted file mode 100644
index 3bcfb56..0000000
--- a/javatests/com/google/gerrit/server/util/ParboiledTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2014 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.util;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.parboiled.BaseParser;
-import org.parboiled.Parboiled;
-import org.parboiled.Rule;
-import org.parboiled.annotations.BuildParseTree;
-import org.parboiled.parserunners.ReportingParseRunner;
-import org.parboiled.support.ParseTreeUtils;
-import org.parboiled.support.ParsingResult;
-
-public class ParboiledTest {
-
-  private static final String EXPECTED =
-      "[Expression] '42'\n"
-          + "  [Term] '42'\n"
-          + "    [Factor] '42'\n"
-          + "      [Number] '42'\n"
-          + "        [0..9] '4'\n"
-          + "        [0..9] '2'\n"
-          + "    [zeroOrMore]\n"
-          + "  [zeroOrMore]\n";
-
-  private CalculatorParser parser;
-
-  @Before
-  public void setUp() {
-    parser = Parboiled.createParser(CalculatorParser.class);
-  }
-
-  @Test
-  public void test() {
-    ParsingResult<String> result = new ReportingParseRunner<String>(parser.Expression()).run("42");
-    assertThat(result.isSuccess()).isTrue();
-    // next test is optional; we could stop here.
-    assertThat(ParseTreeUtils.printNodeTree(result)).isEqualTo(EXPECTED);
-  }
-
-  @BuildParseTree
-  static class CalculatorParser extends BaseParser<Object> {
-    Rule Expression() {
-      return sequence(Term(), zeroOrMore(anyOf("+-"), Term()));
-    }
-
-    Rule Term() {
-      return sequence(Factor(), zeroOrMore(anyOf("*/"), Factor()));
-    }
-
-    Rule Factor() {
-      return firstOf(Number(), sequence('(', Expression(), ')'));
-    }
-
-    Rule Number() {
-      return oneOrMore(charRange('0', '9'));
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/util/git/BUILD b/javatests/com/google/gerrit/server/util/git/BUILD
new file mode 100644
index 0000000..61a776fa
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/git/BUILD
@@ -0,0 +1,31 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "git_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/util/git",
+        "//java/com/google/gerrit/truth",
+        "//java/org/eclipse/jgit:server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
similarity index 91%
rename from javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
rename to javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
index d0225c7..0ec9b38 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
+++ b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
@@ -12,26 +12,24 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.git;
+package com.google.gerrit.server.util.git;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class SubmoduleSectionParserIT extends AbstractDaemonTest {
+public class SubmoduleSectionParserTest {
   private static final String THIS_SERVER = "http://localhost/";
 
   @Test
   public void followMasterBranch() throws Exception {
-    Project.NameKey p = createProject("a");
+    Project.NameKey p = new Project.NameKey("proj");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -56,7 +54,7 @@
 
   @Test
   public void followMatchingBranch() throws Exception {
-    Project.NameKey p = createProject("a");
+    Project.NameKey p = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -92,7 +90,7 @@
 
   @Test
   public void followAnotherBranch() throws Exception {
-    Project.NameKey p = createProject("a");
+    Project.NameKey p = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -117,7 +115,7 @@
 
   @Test
   public void withAnotherURI() throws Exception {
-    Project.NameKey p = createProject("a");
+    Project.NameKey p = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -142,7 +140,7 @@
 
   @Test
   public void withSlashesInProjectName() throws Exception {
-    Project.NameKey p = createProject("project/with/slashes/a");
+    Project.NameKey p = new Project.NameKey("project/with/slashes/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -167,7 +165,7 @@
 
   @Test
   public void withSlashesInPath() throws Exception {
-    Project.NameKey p = createProject("a");
+    Project.NameKey p = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -192,8 +190,8 @@
 
   @Test
   public void withMoreSections() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Project.NameKey p2 = createProject("b");
+    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p2 = new Project.NameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -225,8 +223,8 @@
 
   @Test
   public void withSubProjectFound() throws Exception {
-    Project.NameKey p1 = createProject("a/b");
-    Project.NameKey p2 = createProject("b");
+    Project.NameKey p1 = new Project.NameKey("a/b");
+    Project.NameKey p2 = new Project.NameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -258,10 +256,10 @@
 
   @Test
   public void withAnInvalidSection() throws Exception {
-    Project.NameKey p1 = createProject("a");
-    Project.NameKey p2 = createProject("b");
-    Project.NameKey p3 = createProject("d");
-    Project.NameKey p4 = createProject("e");
+    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p2 = new Project.NameKey("b");
+    Project.NameKey p3 = new Project.NameKey("d");
+    Project.NameKey p4 = new Project.NameKey("e");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -328,7 +326,7 @@
 
   @Test
   public void withSectionToOtherServer() throws Exception {
-    Project.NameKey p1 = createProject("a");
+    Project.NameKey p1 = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -349,7 +347,7 @@
 
   @Test
   public void withRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("a");
+    Project.NameKey p1 = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -374,7 +372,7 @@
 
   @Test
   public void withDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("a");
+    Project.NameKey p1 = new Project.NameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -400,7 +398,7 @@
 
   @Test
   public void withOverlyDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = createProject("nested/a");
+    Project.NameKey p1 = new Project.NameKey("nested/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index 2b1a07e..f6b3e30 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -25,6 +25,7 @@
 import com.google.common.net.HttpHeaders;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.nio.charset.Charset;
 import java.util.Collection;
@@ -106,7 +107,7 @@
   public synchronized PrintWriter getWriter() {
     checkState(outputStream == null, "getOutputStream() already called");
     if (writer == null) {
-      writer = new PrintWriter(actualBody);
+      writer = new PrintWriter(new OutputStreamWriter(actualBody, UTF_8));
     }
     return writer;
   }
diff --git a/lib/BUILD b/lib/BUILD
index c698afb..e5034c9 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -16,14 +16,14 @@
     data = ["//lib:LICENSE-Apache2.0"],
     neverlink = 1,
     visibility = ["//visibility:public"],
-    exports = ["@servlet_api_3_1//jar"],
+    exports = ["@servlet-api-3_1//jar"],
 )
 
 java_library(
     name = "servlet-api-3_1-without-neverlink",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@servlet_api_3_1//jar"],
+    exports = ["@servlet-api-3_1//jar"],
 )
 
 java_library(
@@ -48,17 +48,17 @@
 )
 
 java_library(
-    name = "gwtorm_client",
+    name = "gwtorm-client",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@gwtorm_client//jar"],
+    exports = ["@gwtorm-client//jar"],
 )
 
 java_library(
-    name = "gwtorm_client_src",
+    name = "gwtorm-client_src",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@gwtorm_client//jar:src"],
+    exports = ["@gwtorm-client//jar:src"],
 )
 
 java_library(
@@ -71,8 +71,12 @@
 java_library(
     name = "gwtorm",
     visibility = ["//visibility:public"],
-    exports = [":gwtorm_client"],
-    runtime_deps = [":protobuf"],
+    exports = [":gwtorm-client"],
+    runtime_deps = [
+        ":protobuf",
+        "//lib/antlr:java-runtime",
+        "//lib/ow2:ow2-asm",
+    ],
 )
 
 java_library(
@@ -108,7 +112,7 @@
     name = "args4j",
     data = ["//lib:LICENSE-args4j"],
     visibility = ["//visibility:public"],
-    exports = ["@args4j//jar"],
+    exports = ["@args4j-intern//jar"],
 )
 
 java_library(
@@ -119,53 +123,278 @@
 )
 
 java_library(
-    name = "pegdown",
-    data = ["//lib:LICENSE-Apache2.0"],
+    name = "flexmark",
+    data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@pegdown//jar"],
-    runtime_deps = [":grappa"],
-)
-
-java_library(
-    name = "grappa",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@grappa//jar"],
+    exports = ["@flexmark//jar"],
     runtime_deps = [
-        ":jitescript",
-        "//lib/ow2:ow2-asm",
-        "//lib/ow2:ow2-asm-analysis",
-        "//lib/ow2:ow2-asm-tree",
-        "//lib/ow2:ow2-asm-util",
+        ":flexmark-ext-abbreviation",
     ],
 )
 
 java_library(
-    name = "jitescript",
-    data = ["//lib:LICENSE-Apache2.0"],
+    name = "flexmark-ext-abbreviation",
+    data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@jitescript//jar"],
+    exports = ["@flexmark-ext-abbreviation//jar"],
+    runtime_deps = [
+        ":flexmark-ext-anchorlink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-anchorlink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-anchorlink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-autolink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-autolink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-autolink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-definition",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-definition",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-definition//jar"],
+    runtime_deps = [
+        ":flexmark-ext-emoji",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-emoji",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-emoji//jar"],
+    runtime_deps = [
+        ":flexmark-ext-escaped-character",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-escaped-character",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-escaped-character//jar"],
+    runtime_deps = [
+        ":flexmark-ext-footnotes",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-footnotes",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-footnotes//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-issues",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-issues",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-issues//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-strikethrough",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-strikethrough",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-strikethrough//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-tables",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-tables",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-tables//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-tasklist",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-tasklist",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-tasklist//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-users",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-users",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-users//jar"],
+    runtime_deps = [
+        ":flexmark-ext-ins",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-ins",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-ins//jar"],
+    runtime_deps = [
+        ":flexmark-ext-jekyll-front-matter",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-jekyll-front-matter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-jekyll-front-matter//jar"],
+    runtime_deps = [
+        ":flexmark-ext-superscript",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-superscript",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-superscript//jar"],
+    runtime_deps = [
+        ":flexmark-ext-tables",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-tables",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-tables//jar"],
+    runtime_deps = [
+        ":flexmark-ext-toc",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-toc",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-toc//jar"],
+    runtime_deps = [
+        ":flexmark-ext-typographic",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-typographic",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-typographic//jar"],
+    runtime_deps = [
+        ":flexmark-ext-wikilink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-wikilink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-wikilink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-yaml-front-matter",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-yaml-front-matter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-yaml-front-matter//jar"],
+    runtime_deps = [
+        ":flexmark-formatter",
+    ],
+)
+
+java_library(
+    name = "flexmark-formatter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-formatter//jar"],
+    runtime_deps = [
+        ":flexmark-html-parser",
+    ],
+)
+
+java_library(
+    name = "flexmark-html-parser",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-html-parser//jar"],
+    runtime_deps = [
+        ":flexmark-profile-pegdown",
+    ],
+)
+
+java_library(
+    name = "flexmark-profile-pegdown",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-profile-pegdown//jar"],
+    runtime_deps = [
+        ":flexmark-util",
+    ],
+)
+
+java_library(
+    name = "flexmark-util",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-util//jar"],
+)
+
+java_library(
+    name = "autolink",
+    data = ["//lib:LICENSE-autolink"],
+    visibility = ["//visibility:public"],
+    exports = ["@autolink//jar"],
 )
 
 java_library(
     name = "tukaani-xz",
     data = ["//lib:LICENSE-xz"],
     visibility = ["//visibility:public"],
-    exports = ["@tukaani_xz//jar"],
+    exports = ["@tukaani-xz//jar"],
 )
 
 java_library(
     name = "mime-util",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@mime_util//jar"],
+    exports = ["@mime-util//jar"],
 )
 
 java_library(
     name = "guava-retrying",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guava_retrying//jar"],
+    exports = ["@guava-retrying//jar"],
     runtime_deps = [":jsr305"],
 )
 
@@ -180,7 +409,7 @@
     name = "blame-cache",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@blame_cache//jar"],
+    exports = ["@blame-cache//jar"],
 )
 
 java_library(
@@ -213,7 +442,7 @@
     name = "hamcrest-core",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@hamcrest_core//jar"],
+    exports = ["@hamcrest-core//jar"],
 )
 
 java_library(
@@ -245,7 +474,7 @@
         ":protobuf",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//lib/guice:javax-inject",
+        "//lib/guice:javax_inject",
         "//lib/ow2:ow2-asm",
         "//lib/ow2:ow2-asm-analysis",
         "//lib/ow2:ow2-asm-commons",
@@ -257,7 +486,7 @@
     name = "html-types",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@html_types//jar"],
+    exports = ["@html-types//jar"],
 )
 
 java_library(
diff --git a/lib/LICENSE-Apache1.1 b/lib/LICENSE-Apache1.1
deleted file mode 100644
index 8eda4fc..0000000
--- a/lib/LICENSE-Apache1.1
+++ /dev/null
@@ -1,51 +0,0 @@
-The Apache Software License, Version 1.1
-
-Copyright (c) 2000-2002 The Apache Software Foundation.  All rights
-reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
-
-1. Redistributions of source code must retain the above copyright
-   notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
-   notice, this list of conditions and the following disclaimer in
-   the documentation and/or other materials provided with the
-   distribution.
-
-3. The end-user documentation included with the redistribution,
-   if any, must include the following acknowledgment:
-      "This product includes software developed by the
-       Apache Software Foundation (http://www.apache.org/)."
-   Alternately, this acknowledgment may appear in the software itself,
-   if and wherever such third-party acknowledgments normally appear.
-
-4. The names "Apache" and "Apache Software Foundation", "Jakarta-Oro"
-   must not be used to endorse or promote products derived from this
-   software without prior written permission. For written
-   permission, please contact apache@apache.org.
-
-5. Products derived from this software may not be called "Apache"
-   or "Jakarta-Oro", nor may "Apache" or "Jakarta-Oro" appear in their
-   name, without prior written permission of the Apache Software Foundation.
-
-THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED
-WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED.  IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR
-ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
-USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
-ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
-OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.
-====================================================================
-
-This software consists of voluntary contributions made by many
-individuals on behalf of the Apache Software Foundation.  For more
-information on the Apache Software Foundation, please see
-<http://www.apache.org/>.
diff --git a/lib/LICENSE-autolink b/lib/LICENSE-autolink
new file mode 100644
index 0000000..565820a
--- /dev/null
+++ b/lib/LICENSE-autolink
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Robin Stocker
+
+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/LICENSE-flexmark b/lib/LICENSE-flexmark
new file mode 100644
index 0000000..c5e6ce0
--- /dev/null
+++ b/lib/LICENSE-flexmark
@@ -0,0 +1,26 @@
+Copyright (c) 2015-2016, Atlassian Pty Ltd
+All rights reserved.
+
+Copyright (c) 2016, Vladimir Schneider,
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index 08c320b..c35c2b5 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -10,10 +10,10 @@
 ]]
 
 java_library(
-    name = "java_runtime",
+    name = "java-runtime",
     data = ["//lib:LICENSE-antlr"],
     visibility = ["//visibility:public"],
-    exports = ["@java_runtime//jar"],
+    exports = ["@java-runtime//jar"],
 )
 
 # See https://github.com/bazelbuild/bazel/issues/3542
@@ -29,10 +29,10 @@
 java_library(
     name = "tool",
     data = ["//lib:LICENSE-antlr"],
-    exports = ["@org_antlr//jar"],
+    exports = ["@org-antlr//jar"],
     runtime_deps = [
         ":antlr27",
-        ":java_runtime",
+        ":java-runtime",
         ":stringtemplate",
     ],
 )
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 89adbde..1e722bc 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -2,8 +2,8 @@
     name = "auto-annotation-plugin",
     processor_class = "com.google.auto.value.processor.AutoAnnotationProcessor",
     deps = [
-        "@auto_value//jar",
-        "@auto_value_annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
     ],
 )
 
@@ -11,8 +11,8 @@
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
     deps = [
-        "@auto_value//jar",
-        "@auto_value_annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
     ],
 )
 
@@ -24,7 +24,7 @@
         ":auto-value-plugin",
     ],
     visibility = ["//visibility:public"],
-    exports = ["@auto_value//jar"],
+    exports = ["@auto-value//jar"],
 )
 
 java_library(
@@ -35,5 +35,5 @@
         ":auto-value-plugin",
     ],
     visibility = ["//visibility:public"],
-    exports = ["@auto_value_annotations//jar"],
+    exports = ["@auto-value-annotations//jar"],
 )
diff --git a/lib/codemirror/BUILD b/lib/codemirror/BUILD
index 9c03887..d0c9278 100644
--- a/lib/codemirror/BUILD
+++ b/lib/codemirror/BUILD
@@ -5,7 +5,7 @@
 java_library(
     name = "diff-match-patch",
     data = ["//lib:LICENSE-Apache2.0"],
-    runtime_deps = ["@diff_match_patch//jar"],
+    runtime_deps = ["@diff-match-patch//jar"],
 )
 
 pkg_cm()
diff --git a/lib/codemirror/cm.bzl b/lib/codemirror/cm.bzl
index 54d60d5..593caa3 100644
--- a/lib/codemirror/cm.bzl
+++ b/lib/codemirror/cm.bzl
@@ -55,11 +55,14 @@
     "eclipse",
     "elegant",
     "erlang-dark",
+    "gruvbox-dark",
     "hopscotch",
     "icecoder",
+    "idea",
     "isotope",
     "lesser-dark",
     "liquibyte",
+    "lucario",
     "material",
     "mbo",
     "mdn-like",
@@ -75,6 +78,7 @@
     "rubyblue",
     "seti",
     "solarized",
+    "ssms",
     "the-matrix",
     "tomorrow-night-bright",
     "tomorrow-night-eighties",
@@ -214,7 +218,7 @@
     "z80",
 ]
 
-CM_VERSION = "5.25.0"
+CM_VERSION = "5.37.0"
 
 TOP = "META-INF/resources/webjars/codemirror/%s" % CM_VERSION
 
@@ -230,126 +234,142 @@
                         DIFF_MATCH_PATCH_VERSION)
 
 def pkg_cm():
-  for archive, suffix, top, license in [
-      ('@codemirror_original//jar', '', TOP, LICENSE),
-      ('@codemirror_minified//jar', '_r', TOP_MINIFIED, LICENSE_MINIFIED)
-  ]:
-    # Main JavaScript and addons
-    genrule2(
-      name = 'cm' + suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/' >>$@",
-        ] +
-        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n) for n in CM_JS] +
-        ['unzip -p $(location %s) %s/addon/%s >>$@' % (archive, top, n)
-         for n in CM_ADDONS]
-      ),
-      tools = [archive],
-      outs = ['cm%s.js' % suffix],
-    )
+    for archive, suffix, top, license in [
+        ("@codemirror-original-gwt//jar", "", TOP, LICENSE),
+        ("@codemirror-minified-gwt//jar", "_r", TOP_MINIFIED, LICENSE_MINIFIED),
+    ]:
+        # Main JavaScript and addons
+        genrule2(
+            name = "cm" + suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/' >>$@",
+                ] +
+                ["unzip -p $(location %s) %s/%s >>$@" % (archive, top, n) for n in CM_JS] +
+                [
+                    "unzip -p $(location %s) %s/addon/%s >>$@" % (archive, top, n)
+                    for n in CM_ADDONS
+                ],
+            ),
+            tools = [archive],
+            outs = ["cm%s.js" % suffix],
+        )
 
-    # Main CSS
-    genrule2(
-      name = 'css' + suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/' >>$@",
-        ] +
-        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n)
-         for n in CM_CSS]
-      ),
-      tools = [archive],
-      outs = ['cm%s.css' % suffix],
-    )
+        # Main CSS
+        genrule2(
+            name = "css" + suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/' >>$@",
+                ] +
+                [
+                    "unzip -p $(location %s) %s/%s >>$@" % (archive, top, n)
+                    for n in CM_CSS
+                ],
+            ),
+            tools = [archive],
+            outs = ["cm%s.css" % suffix],
+        )
 
-    # Modes
-    for n in CM_MODES:
-      genrule2(
-        name = 'mode_%s%s' % (n, suffix),
-        cmd = ' && '.join([
-            "echo '/** @license' >$@",
-            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-            "echo '*/' >>$@",
-            'unzip -p $(location %s) %s/mode/%s/%s.js >>$@' % (archive, top, n, n),
-          ]
-        ),
-        tools = [archive],
-        outs = ['mode_%s%s.js' % (n, suffix)],
-      )
+        # Modes
+        for n in CM_MODES:
+            genrule2(
+                name = "mode_%s%s" % (n, suffix),
+                cmd = " && ".join(
+                    [
+                        "echo '/** @license' >$@",
+                        "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                        "echo '*/' >>$@",
+                        "unzip -p $(location %s) %s/mode/%s/%s.js >>$@" % (archive, top, n, n),
+                    ],
+                ),
+                tools = [archive],
+                outs = ["mode_%s%s.js" % (n, suffix)],
+            )
 
-    # Themes
-    for n in CM_THEMES:
-      genrule2(
-        name = 'theme_%s%s' % (n, suffix),
-        cmd = ' && '.join([
-            "echo '/** @license' >$@",
-            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-            "echo '*/' >>$@",
-            'unzip -p $(location %s) %s/theme/%s.css >>$@' % (archive, top, n)
-          ]
-        ),
-        tools = [archive],
-        outs = ['theme_%s%s.css' % (n, suffix)],
-      )
+        # Themes
+        for n in CM_THEMES:
+            genrule2(
+                name = "theme_%s%s" % (n, suffix),
+                cmd = " && ".join(
+                    [
+                        "echo '/** @license' >$@",
+                        "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                        "echo '*/' >>$@",
+                        "unzip -p $(location %s) %s/theme/%s.css >>$@" % (archive, top, n),
+                    ],
+                ),
+                tools = [archive],
+                outs = ["theme_%s%s.css" % (n, suffix)],
+            )
 
-    # Merge Addon bundled with diff-match-patch
-    genrule2(
-      name = 'addon_merge_with_diff_match_patch%s' % suffix,
-      cmd = ' && '.join([
-          "echo '/** @license' >$@",
-          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
-          "echo '*/\n' >>$@",
-          "echo '// The google-diff-match-patch library is from https://repo1.maven.org/maven2/org/webjars/google-diff-match-patch/%s/google-diff-match-patch-%s.jar\n' >> $@" % (DIFF_MATCH_PATCH_VERSION, DIFF_MATCH_PATCH_VERSION),
-          "echo '/** @license' >>$@",
-          "echo 'LICENSE-Apache2.0' >>$@",
-          "echo '*/' >>$@",
-          'unzip -p $(location @diff_match_patch//jar) %s/diff_match_patch.js >>$@' % DIFF_MATCH_PATCH_TOP,
-          "echo ';' >> $@",
-          'unzip -p $(location %s) %s/addon/merge/merge.js >>$@' % (archive, top)
-        ]
-      ),
-      tools = [
-        '@diff_match_patch//jar',
-        # dependency just for license tracking.
-        ':diff-match-patch',
-        archive,
-        "//lib:LICENSE-Apache2.0",
-      ],
-      outs = ['addon_merge_with_diff_match_patch%s.js' % suffix],
-    )
+        # Merge Addon bundled with diff-match-patch
+        genrule2(
+            name = "addon_merge_with_diff_match_patch%s" % suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/\n' >>$@",
+                    "echo '// The google-diff-match-patch library is from https://repo1.maven.org/maven2/org/webjars/google-diff-match-patch/%s/google-diff-match-patch-%s.jar\n' >> $@" % (DIFF_MATCH_PATCH_VERSION, DIFF_MATCH_PATCH_VERSION),
+                    "echo '/** @license' >>$@",
+                    "echo 'LICENSE-Apache2.0' >>$@",
+                    "echo '*/' >>$@",
+                    "unzip -p $(location @diff-match-patch//jar) %s/diff_match_patch.js >>$@" % DIFF_MATCH_PATCH_TOP,
+                    "echo ';' >> $@",
+                    "unzip -p $(location %s) %s/addon/merge/merge.js >>$@" % (archive, top),
+                ],
+            ),
+            tools = [
+                "@diff-match-patch//jar",
+                # dependency just for license tracking.
+                ":diff-match-patch",
+                archive,
+                "//lib:LICENSE-Apache2.0",
+            ],
+            outs = ["addon_merge_with_diff_match_patch%s.js" % suffix],
+        )
 
-    # Jar packaging
-    genrule2(
-      name = 'jar' + suffix,
-      cmd = ' && '.join([
-        'cd $$TMP',
-        'mkdir -p net/codemirror/{addon,lib,mode,theme}',
-        'cp $$ROOT/$(location :css%s) net/codemirror/lib/cm.css' % suffix,
-        'cp $$ROOT/$(location :cm%s) net/codemirror/lib/cm.js' % suffix]
-        + ['cp $$ROOT/$(location :mode_%s%s) net/codemirror/mode/%s.js' % (n, suffix, n)
-           for n in CM_MODES]
-        + ['cp $$ROOT/$(location :theme_%s%s) net/codemirror/theme/%s.css' % (n, suffix, n)
-           for n in CM_THEMES]
-        + ['cp $$ROOT/$(location :addon_merge_with_diff_match_patch%s) net/codemirror/addon/merge_bundled.js' % suffix]
-        + ['zip -qr $$ROOT/$@ net/codemirror/{addon,lib,mode,theme}']),
-      tools = [
-        ':addon_merge_with_diff_match_patch%s' % suffix,
-        ':cm%s' % suffix,
-        ':css%s' % suffix,
-      ] + [
-        ':mode_%s%s' % (n, suffix) for n in CM_MODES
-      ] + [
-        ':theme_%s%s' % (n, suffix) for n in CM_THEMES
-      ],
-      outs = ['codemirror%s.jar' % suffix],
-    )
+        # Jar packaging
+        genrule2(
+            name = "jar" + suffix,
+            cmd = " && ".join([
+                                  "cd $$TMP",
+                                  "mkdir -p net/codemirror/{addon,lib,mode,theme}",
+                                  "cp $$ROOT/$(location :css%s) net/codemirror/lib/cm.css" % suffix,
+                                  "cp $$ROOT/$(location :cm%s) net/codemirror/lib/cm.js" % suffix,
+                              ] +
+                              [
+                                  "cp $$ROOT/$(location :mode_%s%s) net/codemirror/mode/%s.js" % (n, suffix, n)
+                                  for n in CM_MODES
+                              ] +
+                              [
+                                  "cp $$ROOT/$(location :theme_%s%s) net/codemirror/theme/%s.css" % (n, suffix, n)
+                                  for n in CM_THEMES
+                              ] +
+                              ["cp $$ROOT/$(location :addon_merge_with_diff_match_patch%s) net/codemirror/addon/merge_bundled.js" % suffix] +
+                              ["zip -qr $$ROOT/$@ net/codemirror/{addon,lib,mode,theme}"]),
+            tools = [
+                ":addon_merge_with_diff_match_patch%s" % suffix,
+                ":cm%s" % suffix,
+                ":css%s" % suffix,
+            ] + [
+                ":mode_%s%s" % (n, suffix)
+                for n in CM_MODES
+            ] + [
+                ":theme_%s%s" % (n, suffix)
+                for n in CM_THEMES
+            ],
+            outs = ["codemirror%s.jar" % suffix],
+        )
 
-    native.java_import(
-      name = 'codemirror' + suffix,
-      jars = [':jar%s' % suffix],
-      visibility = ['//visibility:public'],
-      data = [license],
-    )
+        native.java_import(
+            name = "codemirror" + suffix,
+            jars = [":jar%s" % suffix],
+            visibility = ["//visibility:public"],
+            data = [license],
+        )
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index cb81a1d..bb36389 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -4,41 +4,41 @@
     name = "codec",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_codec//jar"],
+    exports = ["@commons-codec//jar"],
 )
 
 java_library(
     name = "compress",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_compress//jar"],
+    exports = ["@commons-compress//jar"],
 )
 
 java_library(
     name = "lang",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_lang//jar"],
+    exports = ["@commons-lang//jar"],
 )
 
 java_library(
     name = "lang3",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@commons_lang3//jar"],
+    exports = ["@commons-lang3//jar"],
 )
 
 java_library(
     name = "net",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_net//jar"],
+    exports = ["@commons-net//jar"],
 )
 
 java_library(
     name = "dbcp",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_dbcp//jar"],
+    exports = ["@commons-dbcp//jar"],
     runtime_deps = [":pool"],
 )
 
@@ -46,19 +46,19 @@
     name = "pool",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_pool//jar"],
+    exports = ["@commons-pool//jar"],
 )
 
 java_library(
     name = "validator",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_validator//jar"],
+    exports = ["@commons-validator//jar"],
 )
 
 java_library(
     name = "io",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@commons_io//jar"],
+    exports = ["@commons-io//jar"],
 )
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD
index dd14699..4ae12f1 100644
--- a/lib/dropwizard/BUILD
+++ b/lib/dropwizard/BUILD
@@ -2,5 +2,5 @@
     name = "dropwizard-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@dropwizard_core//jar"],
+    exports = ["@dropwizard-core//jar"],
 )
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
index b579ec5..352d2a7 100644
--- a/lib/easymock/BUILD
+++ b/lib/easymock/BUILD
@@ -13,7 +13,7 @@
     name = "cglib-3_2",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@cglib_3_2//jar"],
+    exports = ["@cglib-3_2//jar"],
 )
 
 java_library(
diff --git a/lib/fonts/RobotoMono-Regular.woff b/lib/fonts/RobotoMono-Regular.woff
old mode 100755
new mode 100644
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff2 b/lib/fonts/RobotoMono-Regular.woff2
old mode 100755
new mode 100644
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff b/lib/fonts/SourceCodePro-Regular.woff
deleted file mode 100644
index 395436e..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff2 b/lib/fonts/SourceCodePro-Regular.woff2
deleted file mode 100644
index 65cd591..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 9a9bf94..7f384e2 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -3,16 +3,16 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = [
-        ":guice_library",
-        ":javax-inject",
+        ":guice-library",
+        ":javax_inject",
     ],
 )
 
 java_library(
-    name = "guice_library",
+    name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guice_library//jar"],
+    exports = ["@guice-library//jar"],
     runtime_deps = ["aopalliance"],
 )
 
@@ -20,7 +20,7 @@
     name = "guice-assistedinject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guice_assistedinject//jar"],
+    exports = ["@guice-assistedinject//jar"],
     runtime_deps = [":guice"],
 )
 
@@ -28,7 +28,7 @@
     name = "guice-servlet",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@guice_servlet//jar"],
+    exports = ["@guice-servlet//jar"],
     runtime_deps = [":guice"],
 )
 
@@ -39,7 +39,7 @@
 )
 
 java_library(
-    name = "javax-inject",
+    name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = ["@javax_inject//jar"],
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
index 487e05b..fa2fef3 100644
--- a/lib/gwt/BUILD
+++ b/lib/gwt/BUILD
@@ -2,7 +2,7 @@
     name = n,
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@%s//jar" % n.replace("-", "_")],
+    exports = ["@%s//jar" % n],
 ) for n in [
     "ant",
     "colt",
@@ -34,12 +34,12 @@
     name = "javax-validation_src",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@javax_validation//jar:src"],
+    exports = ["@javax-validation//jar:src"],
 )
 
 java_library(
     name = "jsinterop-annotations_src",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jsinterop_annotations//jar:src"],
+    exports = ["@jsinterop-annotations//jar:src"],
 )
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
index bd1cd54..3496c69 100644
--- a/lib/highlightjs/building.md
+++ b/lib/highlightjs/building.md
@@ -20,7 +20,7 @@
 languages included. Build it with the following:
 
     $>  # start in some temp directory
-    $>  git clone https://github.com/isagalaev/highlight.js.git
+    $>  git clone https://github.com/highlightjs/highlight.js
     $>  cd highlight.js
     $>  node tools/build.js -n \
           bash \
@@ -61,10 +61,15 @@
 
 ## Minification
 
-Minify the file using closure-compiler using the command below. (Modify
-`/path/to` with the path to your compiler jar.)
+Minify the file using closure-compiler using the command below.
 
-    $>  java -jar /path/to/closure-compiler.jar \
+    $> wget https://dl.google.com/closure-compiler/compiler-latest.zip
+
+    $> unzip compiler-latest.zip
+
+    $> mv closure-compiler-*.jar closure-compiler.jar
+
+    $>  java -jar ./closure-compiler.jar \
             --js build/highlight.pack.js \
             --js_output_file build/highlight.min.js
 
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
index 9775c0d..069a018 100644
--- a/lib/highlightjs/highlight.min.js
+++ b/lib/highlightjs/highlight.min.js
@@ -1,62 +1,124 @@
 /*
  highlight.js v9.12.0 | BSD3 License | git.io/hljslicense */
-(function(b){var l="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):l&&(l.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return l.hljs}))})(function(b){function l(a){return a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function C(a,d){var e=a&&a.exec(d);return e&&0===e.index}function r(a){var d,e={},b=Array.prototype.slice.call(arguments,1);for(d in a)e[d]=a[d];b.forEach(function(a){for(d in a)e[d]=
-a[d]});return e}function G(a){var d=[];(function h(a,b){for(var k=a.firstChild;k;k=k.nextSibling)3===k.nodeType?b+=k.nodeValue.length:1===k.nodeType&&(d.push({event:"start",offset:b,node:k}),b=h(k,b),k.nodeName.toLowerCase().match(/br|hr|img|input/)||d.push({event:"stop",offset:b,node:k}));return b})(a,0);return d}function L(a,d,e){function b(){return a.length&&d.length?a[0].offset!==d[0].offset?a[0].offset<d[0].offset?a:d:"start"===d[0].event?a:d:a.length?a:d}function c(a){n+="<"+a.nodeName.toLowerCase()+
-H.map.call(a.attributes,function(a){return" "+a.nodeName+'="'+l(a.value).replace('"',"&quot;")+'"'}).join("")+">"}function f(a){n+="</"+a.nodeName.toLowerCase()+">"}function k(a){("start"===a.event?c:f)(a.node)}for(var v=0,n="",p=[];a.length||d.length;){var g=b(),n=n+l(e.substring(v,g[0].offset)),v=g[0].offset;if(g===a){p.reverse().forEach(f);do k(g.splice(0,1)[0]),g=b();while(g===a&&g.length&&g[0].offset===v);p.reverse().forEach(c)}else"start"===g[0].event?p.push(g[0].node):p.pop(),k(g.splice(0,
-1)[0])}return n+l(e.substr(v))}function M(a){a.variants&&!a.cached_variants&&(a.cached_variants=a.variants.map(function(d){return r(a,{variants:null},d)}));return a.cached_variants||a.endsWithParent&&[r(a)]||[a]}function N(a){function d(a){return a&&a.source||a}function e(c,e){return new RegExp(d(c),"m"+(a.case_insensitive?"i":"")+(e?"g":""))}function b(c,f){if(!c.compiled){c.compiled=!0;c.keywords=c.keywords||c.beginKeywords;if(c.keywords){var k={},l=function(d,c){a.case_insensitive&&(c=c.toLowerCase());
-c.split(" ").forEach(function(a){a=a.split("|");k[a[0]]=[d,a[1]?Number(a[1]):1]})};"string"===typeof c.keywords?l("keyword",c.keywords):w(c.keywords).forEach(function(a){l(a,c.keywords[a])});c.keywords=k}c.lexemesRe=e(c.lexemes||/\w+/,!0);f&&(c.beginKeywords&&(c.begin="\\b("+c.beginKeywords.split(" ").join("|")+")\\b"),c.begin||(c.begin=/\B|\b/),c.beginRe=e(c.begin),c.end||c.endsWithParent||(c.end=/\B|\b/),c.end&&(c.endRe=e(c.end)),c.terminator_end=d(c.end)||"",c.endsWithParent&&f.terminator_end&&
-(c.terminator_end+=(c.end?"|":"")+f.terminator_end));c.illegal&&(c.illegalRe=e(c.illegal));null==c.relevance&&(c.relevance=1);c.contains||(c.contains=[]);c.contains=Array.prototype.concat.apply([],c.contains.map(function(a){return M("self"===a?c:a)}));c.contains.forEach(function(a){b(a,c)});c.starts&&b(c.starts,f);var n=c.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([c.terminator_end,c.illegal]).map(d).filter(Boolean);c.terminators=n.length?e(n.join("|"),
-!0):{exec:function(){return null}}}}b(a)}function A(a,d,e,b){function c(a,d){if(C(a.endRe,d)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return c(a.parent,d)}function f(a,d,b,c){return'<span class="'+(c?"":q.classPrefix)+(a+'">')+d+(b?"":"</span>")}function k(){var a=t,d;if(null!=g.subLanguage)if((d="string"===typeof g.subLanguage)&&!x[g.subLanguage])d=l(m);else{var b=d?A(g.subLanguage,m,!0,u[g.subLanguage]):E(m,g.subLanguage.length?g.subLanguage:void 0);0<g.relevance&&(r+=
-b.relevance);d&&(u[g.subLanguage]=b.top);d=f(b.language,b.value,!1,!0)}else{var c;if(g.keywords){b="";c=0;g.lexemesRe.lastIndex=0;for(d=g.lexemesRe.exec(m);d;){b+=l(m.substring(c,d.index));c=g;var e=d,e=p.case_insensitive?e[0].toLowerCase():e[0];(c=c.keywords.hasOwnProperty(e)&&c.keywords[e])?(r+=c[1],b+=f(c[0],l(d[0]))):b+=l(d[0]);c=g.lexemesRe.lastIndex;d=g.lexemesRe.exec(m)}d=b+l(m.substr(c))}else d=l(m)}t=a+d;m=""}function v(a){t+=a.className?f(a.className,"",!0):"";g=Object.create(a,{parent:{value:g}})}
-function n(a,d){m+=a;if(null==d)return k(),0;var b;a:{b=g;var f,h;f=0;for(h=b.contains.length;f<h;f++)if(C(b.contains[f].beginRe,d)){b=b.contains[f];break a}b=void 0}if(b)return b.skip?m+=d:(b.excludeBegin&&(m+=d),k(),b.returnBegin||b.excludeBegin||(m=d)),v(b,d),b.returnBegin?0:d.length;if(b=c(g,d)){f=g;f.skip?m+=d:(f.returnEnd||f.excludeEnd||(m+=d),k(),f.excludeEnd&&(m=d));do g.className&&(t+="</span>"),g.skip||(r+=g.relevance),g=g.parent;while(g!==b.parent);b.starts&&v(b.starts,"");return f.returnEnd?
-0:d.length}if(!e&&C(g.illegalRe,d))throw Error('Illegal lexeme "'+d+'" for mode "'+(g.className||"<unnamed>")+'"');m+=d;return d.length||1}var p=y(a);if(!p)throw Error('Unknown language: "'+a+'"');N(p);var g=b||p,u={},t="";for(b=g;b!==p;b=b.parent)b.className&&(t=f(b.className,"",!0)+t);var m="",r=0;try{for(var z,w,B=0;;){g.terminators.lastIndex=B;z=g.terminators.exec(d);if(!z)break;w=n(d.substring(B,z.index),z[0]);B=z.index+w}n(d.substr(B));for(b=g;b.parent;b=b.parent)b.className&&(t+="</span>");
-return{relevance:r,value:t,language:a,top:g}}catch(D){if(D.message&&-1!==D.message.indexOf("Illegal"))return{relevance:0,value:l(d)};throw D;}}function E(a,d){d=d||q.languages||w(x);var b={relevance:0,value:l(a)},h=b;d.filter(y).forEach(function(d){var f=A(d,a,!1);f.language=d;f.relevance>h.relevance&&(h=f);f.relevance>b.relevance&&(h=b,b=f)});h.language&&(b.second_best=h);return b}function I(a){return q.tabReplace||q.useBR?a.replace(O,function(a,b){return q.useBR&&"\n"===a?"<br>":q.tabReplace?b.replace(/\t/g,
-q.tabReplace):""}):a}function J(a){var d,b,h,c,f;a:if(b=a.className+" ",b+=a.parentNode?a.parentNode.className:"",f=P.exec(b))f=y(f[1])?f[1]:"no-highlight";else{b=b.split(/\s+/);f=0;for(c=b.length;f<c;f++)if(d=b[f],K.test(d)||y(d)){f=d;break a}f=void 0}K.test(f)||(q.useBR?(d=document.createElementNS("http://www.w3.org/1999/xhtml","div"),d.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):d=a,c=d.textContent,b=f?A(f,c,!0):E(c),d=G(d),d.length&&(h=document.createElementNS("http://www.w3.org/1999/xhtml",
-"div"),h.innerHTML=b.value,b.value=L(d,G(h),c)),b.value=I(b.value),a.innerHTML=b.value,c=a.className,f=f?F[f]:b.language,d=[c.trim()],c.match(/\bhljs\b/)||d.push("hljs"),-1===c.indexOf(f)&&d.push(f),f=d.join(" ").trim(),a.className=f,a.result={language:b.language,re:b.relevance},b.second_best&&(a.second_best={language:b.second_best.language,re:b.second_best.relevance}))}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");H.forEach.call(a,J)}}function y(a){a=(a||"").toLowerCase();
-return x[a]||x[F[a]]}var H=[],w=Object.keys,x={},F={},K=/^(no-?highlight|plain|text)$/i,P=/\blang(?:uage)?-([\w-]+)\b/i,O=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,q={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};b.highlight=A;b.highlightAuto=E;b.fixMarkup=I;b.highlightBlock=J;b.configure=function(a){q=r(q,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,d){var e=x[a]=d(b);e.aliases&&
-e.aliases.forEach(function(b){F[b]=a})};b.listLanguages=function(){return w(x)};b.getLanguage=y;b.inherit=r;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE=
-{begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/};b.COMMENT=function(a,d,e){a=b.inherit({className:"comment",begin:a,end:d,contains:[]},e||{});
+var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(b,g,l){b!=Array.prototype&&b!=Object.prototype&&(b[g]=l.value)};$jscomp.getGlobal=function(b){return"undefined"!=typeof window&&window===b?b:"undefined"!=typeof global&&null!=global?global:b};$jscomp.global=$jscomp.getGlobal(this);$jscomp.SYMBOL_PREFIX="jscomp_symbol_";
+$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};$jscomp.symbolCounter_=0;$jscomp.Symbol=function(b){return $jscomp.SYMBOL_PREFIX+(b||"")+$jscomp.symbolCounter_++};
+$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var b=$jscomp.global.Symbol.iterator;b||(b=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("iterator"));"function"!=typeof Array.prototype[b]&&$jscomp.defineProperty(Array.prototype,b,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}});$jscomp.initSymbolIterator=function(){}};$jscomp.arrayIterator=function(b){var g=0;return $jscomp.iteratorPrototype(function(){return g<b.length?{done:!1,value:b[g++]}:{done:!0}})};
+$jscomp.iteratorPrototype=function(b){$jscomp.initSymbolIterator();b={next:b};b[$jscomp.global.Symbol.iterator]=function(){return this};return b};$jscomp.iteratorFromArray=function(b,g){$jscomp.initSymbolIterator();b instanceof String&&(b+="");var l=0,k={next:function(){if(l<b.length){var m=l++;return{value:g(m,b[m]),done:!1}}k.next=function(){return{done:!0,value:void 0}};return k.next()}};k[Symbol.iterator]=function(){return k};return k};
+$jscomp.polyfill=function(b,g,l,k){if(g){l=$jscomp.global;b=b.split(".");for(k=0;k<b.length-1;k++){var m=b[k];m in l||(l[m]={});l=l[m]}b=b[b.length-1];k=l[b];g=g(k);g!=k&&null!=g&&$jscomp.defineProperty(l,b,{configurable:!0,writable:!0,value:g})}};$jscomp.polyfill("Array.prototype.keys",function(b){return b?b:function(){return $jscomp.iteratorFromArray(this,function(b){return b})}},"es6","es3");
+(function(b){var g="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):g&&(g.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return g.hljs}))})(function(b){function g(a){return a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function l(a,f){return(a=a&&a.exec(f))&&0===a.index}function k(a){var f,b={},e=Array.prototype.slice.call(arguments,1);for(f in a)b[f]=a[f];e.forEach(function(a){for(f in a)b[f]=a[f]});
+return b}function m(a){var f=[];(function e(a,b){for(a=a.firstChild;a;a=a.nextSibling)3===a.nodeType?b+=a.nodeValue.length:1===a.nodeType&&(f.push({event:"start",offset:b,node:a}),b=e(a,b),a.nodeName.toLowerCase().match(/br|hr|img|input/)||f.push({event:"stop",offset:b,node:a}));return b})(a,0);return f}function L(a,f,b){function d(){return a.length&&f.length?a[0].offset!==f[0].offset?a[0].offset<f[0].offset?a:f:"start"===f[0].event?a:f:a.length?a:f}function c(a){B+="<"+a.nodeName.toLowerCase()+H.map.call(a.attributes,
+function(a){return" "+a.nodeName+'="'+g(a.value).replace('"',"&quot;")+'"'}).join("")+">"}function q(a){B+="</"+a.nodeName.toLowerCase()+">"}function w(a){("start"===a.event?c:q)(a.node)}for(var r=0,B="",n=[];a.length||f.length;){var h=d();B+=g(b.substring(r,h[0].offset));r=h[0].offset;if(h===a){n.reverse().forEach(q);do w(h.splice(0,1)[0]),h=d();while(h===a&&h.length&&h[0].offset===r);n.reverse().forEach(c)}else"start"===h[0].event?n.push(h[0].node):n.pop(),w(h.splice(0,1)[0])}return B+g(b.substr(r))}
+function M(a){a.variants&&!a.cached_variants&&(a.cached_variants=a.variants.map(function(f){return k(a,{variants:null},f)}));return a.cached_variants||a.endsWithParent&&[k(a)]||[a]}function N(a){function f(a){return a&&a.source||a}function b(b,d){return new RegExp(f(b),"m"+(a.case_insensitive?"i":"")+(d?"g":""))}function e(c,d){if(!c.compiled){c.compiled=!0;c.keywords=c.keywords||c.beginKeywords;if(c.keywords){var q={},g=function(f,b){a.case_insensitive&&(b=b.toLowerCase());b.split(" ").forEach(function(a){a=
+a.split("|");q[a[0]]=[f,a[1]?Number(a[1]):1]})};"string"===typeof c.keywords?g("keyword",c.keywords):x(c.keywords).forEach(function(a){g(a,c.keywords[a])});c.keywords=q}c.lexemesRe=b(c.lexemes||/\w+/,!0);d&&(c.beginKeywords&&(c.begin="\\b("+c.beginKeywords.split(" ").join("|")+")\\b"),c.begin||(c.begin=/\B|\b/),c.beginRe=b(c.begin),c.end||c.endsWithParent||(c.end=/\B|\b/),c.end&&(c.endRe=b(c.end)),c.terminator_end=f(c.end)||"",c.endsWithParent&&d.terminator_end&&(c.terminator_end+=(c.end?"|":"")+
+d.terminator_end));c.illegal&&(c.illegalRe=b(c.illegal));null==c.relevance&&(c.relevance=1);c.contains||(c.contains=[]);c.contains=Array.prototype.concat.apply([],c.contains.map(function(a){return M("self"===a?c:a)}));c.contains.forEach(function(a){e(a,c)});c.starts&&e(c.starts,d);d=c.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([c.terminator_end,c.illegal]).map(f).filter(Boolean);c.terminators=d.length?b(d.join("|"),!0):{exec:function(){return null}}}}
+e(a)}function C(a,f,b,e){function c(a,b){if(l(a.endRe,b)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return c(a.parent,b)}function d(a,b,f,d){return'<span class="'+(d?"":t.classPrefix)+(a+'">')+b+(f?"":"</span>")}function w(){var a=v,b;if(null!=h.subLanguage)if((b="string"===typeof h.subLanguage)&&!y[h.subLanguage])b=g(p);else{var f=b?C(h.subLanguage,p,!0,m[h.subLanguage]):F(p,h.subLanguage.length?h.subLanguage:void 0);0<h.relevance&&(u+=f.relevance);b&&(m[h.subLanguage]=
+f.top);b=d(f.language,f.value,!1,!0)}else if(h.keywords){f="";var c=0;h.lexemesRe.lastIndex=0;for(b=h.lexemesRe.exec(p);b;){f+=g(p.substring(c,b.index));c=h;var e=b;e=n.case_insensitive?e[0].toLowerCase():e[0];(c=c.keywords.hasOwnProperty(e)&&c.keywords[e])?(u+=c[1],f+=d(c[0],g(b[0]))):f+=g(b[0]);c=h.lexemesRe.lastIndex;b=h.lexemesRe.exec(p)}b=f+g(p.substr(c))}else b=g(p);v=a+b;p=""}function r(a){v+=a.className?d(a.className,"",!0):"";h=Object.create(a,{parent:{value:h}})}function k(a,f){p+=a;if(null==
+f)return w(),0;a:{a=h;var d;var e=0;for(d=a.contains.length;e<d;e++)if(l(a.contains[e].beginRe,f)){a=a.contains[e];break a}a=void 0}if(a)return a.skip?p+=f:(a.excludeBegin&&(p+=f),w(),a.returnBegin||a.excludeBegin||(p=f)),r(a,f),a.returnBegin?0:f.length;if(a=c(h,f)){e=h;e.skip?p+=f:(e.returnEnd||e.excludeEnd||(p+=f),w(),e.excludeEnd&&(p=f));do h.className&&(v+="</span>"),h.skip||(u+=h.relevance),h=h.parent;while(h!==a.parent);a.starts&&r(a.starts,"");return e.returnEnd?0:f.length}if(!b&&l(h.illegalRe,
+f))throw Error('Illegal lexeme "'+f+'" for mode "'+(h.className||"<unnamed>")+'"');p+=f;return f.length||1}var n=z(a);if(!n)throw Error('Unknown language: "'+a+'"');N(n);var h=e||n,m={},v="";for(e=h;e!==n;e=e.parent)e.className&&(v=d(e.className,"",!0)+v);var p="",u=0;try{for(var A,x,D=0;;){h.terminators.lastIndex=D;A=h.terminators.exec(f);if(!A)break;x=k(f.substring(D,A.index),A[0]);D=A.index+x}k(f.substr(D));for(e=h;e.parent;e=e.parent)e.className&&(v+="</span>");return{relevance:u,value:v,language:a,
+top:h}}catch(E){if(E.message&&-1!==E.message.indexOf("Illegal"))return{relevance:0,value:g(f)};throw E;}}function F(a,f){f=f||t.languages||x(y);var b={relevance:0,value:g(a)},e=b;f.filter(z).forEach(function(f){var c=C(f,a,!1);c.language=f;c.relevance>e.relevance&&(e=c);c.relevance>b.relevance&&(e=b,b=c)});e.language&&(b.second_best=e);return b}function I(a){return t.tabReplace||t.useBR?a.replace(O,function(a,b){return t.useBR&&"\n"===a?"<br>":t.tabReplace?b.replace(/\t/g,t.tabReplace):""}):a}function J(a){var b,
+d;a:{var e=a.className+" ";e+=a.parentNode?a.parentNode.className:"";if(d=P.exec(e))d=z(d[1])?d[1]:"no-highlight";else{e=e.split(/\s+/);d=0;for(b=e.length;d<b;d++){var c=e[d];if(K.test(c)||z(c)){d=c;break a}}d=void 0}}if(!K.test(d)){t.useBR?(c=document.createElementNS("http://www.w3.org/1999/xhtml","div"),c.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):c=a;b=c.textContent;e=d?C(d,b,!0):F(b);c=m(c);if(c.length){var q=document.createElementNS("http://www.w3.org/1999/xhtml","div");
+q.innerHTML=e.value;e.value=L(c,m(q),b)}e.value=I(e.value);a.innerHTML=e.value;b=a.className;d=d?G[d]:e.language;c=[b.trim()];b.match(/\bhljs\b/)||c.push("hljs");-1===b.indexOf(d)&&c.push(d);d=c.join(" ").trim();a.className=d;a.result={language:e.language,re:e.relevance};e.second_best&&(a.second_best={language:e.second_best.language,re:e.second_best.relevance})}}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");H.forEach.call(a,J)}}function z(a){a=(a||"").toLowerCase();
+return y[a]||y[G[a]]}var H=[],x=Object.keys,y={},G={},K=/^(no-?highlight|plain|text)$/i,P=/\blang(?:uage)?-([\w-]+)\b/i,O=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,t={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};b.highlight=C;b.highlightAuto=F;b.fixMarkup=I;b.highlightBlock=J;b.configure=function(a){t=k(t,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,f){f=y[a]=f(b);f.aliases&&
+f.aliases.forEach(function(b){G[b]=a})};b.listLanguages=function(){return x(y)};b.getLanguage=z;b.inherit=k;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE=
+{begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/};b.COMMENT=function(a,f,d){a=b.inherit({className:"comment",begin:a,end:f,contains:[]},d||{});
 a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#","$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE={className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",
 begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};
-b.registerLanguage("bash",function(a){var b={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},e={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
-_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,e,{className:"string",begin:/'/,end:/'/},b]}});b.registerLanguage("cpp",function(a){var b={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},e={className:"string",variants:[{begin:'(u8?|U)?L?"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},
-{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},h={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},contains:[{begin:/\\\n/,
-relevance:0},a.inherit(e,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},f=a.IDENT_RE+"\\s*\\(",k={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",
+b.registerLanguage("bash",function(a){var b={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},d={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
+_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,d,{className:"string",begin:/'/,end:/'/},b]}});b.registerLanguage("clojure",function(a){var b={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},d=a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),e=a.COMMENT(";","$",{relevance:0}),
+c={className:"literal",begin:/\b(true|false|nil)\b/},q={begin:"[\\[\\{]",end:"[\\]\\}]"},g={className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},r=a.COMMENT("\\^\\{","\\}"),k={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},n={begin:"\\(",end:"\\)"},h={endsWithParent:!0,relevance:0},l={keywords:{"builtin-name":"def defonce cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},
+lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:h},m=[n,d,g,r,e,k,q,b,c,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];n.contains=[a.COMMENT("comment",""),l,h];h.contains=m;q.contains=m;r.contains=[q];return{aliases:["clj"],illegal:/\S/,contains:[n,d,g,r,e,k,q,b,c]}});b.registerLanguage("cpp",function(a){var b={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},d={className:"string",
+variants:[{begin:'(u8?|U)?L?"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},e={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},
+contains:[{begin:/\\\n/,relevance:0},a.inherit(d,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},q=a.IDENT_RE+"\\s*\\(",g={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",
 built_in:"std string cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr",
-literal:"true false nullptr NULL"},l=[b,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,e];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:k,illegal:"</",contains:l.concat([c,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:k,contains:["self",b]},{begin:a.IDENT_RE+"::",keywords:k},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
-end:/;/}],keywords:k,contains:l.concat([{begin:/\(/,end:/\)/,keywords:k,contains:l.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:k,illegal:/[^\w\s\*&]/,contains:[{begin:f,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,h,b]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
-c]},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin:/</,end:/>/,contains:["self"]},a.TITLE_MODE]}]),exports:{preprocessor:c,strings:e,keywords:k}}});b.registerLanguage("css",function(a){return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},
-{begin:"@(font-face|page)",lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",
-begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
+literal:"true false nullptr NULL"},k=[b,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,d];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:g,illegal:"</",contains:k.concat([c,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:g,contains:["self",b]},{begin:a.IDENT_RE+"::",keywords:g},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
+end:/;/}],keywords:g,contains:k.concat([{begin:/\(/,end:/\)/,keywords:g,contains:k.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+q,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:g,illegal:/[^\w\s\*&]/,contains:[{begin:q,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:g,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,e,b]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
+c]},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin:/</,end:/>/,contains:["self"]},a.TITLE_MODE]}]),exports:{preprocessor:c,strings:d,keywords:g}}});b.registerLanguage("cs",function(a){var b={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long nameof object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let on orderby partial remove select set value var where yield",
+literal:"null false true"},d={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},e=a.inherit(d,{illegal:/\n/}),c={className:"subst",begin:"{",end:"}",keywords:b},g=a.inherit(c,{illegal:/\n/}),k={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},a.BACKSLASH_ESCAPE,g]},l={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},c]},m=a.inherit(l,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},g]});c.contains=
+[l,k,d,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE];g.contains=[m,k,e,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.inherit(a.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];d={variants:[l,k,d,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]};e=a.IDENT_RE+"(<"+a.IDENT_RE+"(\\s*,\\s*"+a.IDENT_RE+")*>)?(\\[\\])?";return{aliases:["csharp"],keywords:b,illegal:/::/,contains:[a.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},
+{begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},d,a.C_NUMBER_MODE,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.TITLE_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),
+a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+e+"\\s+)+"+a.IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:b,contains:[{begin:a.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
+keywords:b,relevance:0,contains:[d,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}});b.registerLanguage("css",function(a){return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",
+lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",
+excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("d",function(a){var b=a.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{lexemes:a.UNDERSCORE_IDENT_RE,
+keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
+built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},{className:"string",begin:'"',contains:[{begin:"\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",relevance:0}],
+end:'"[cwd]?'},{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},{className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(i|[fF]i|Li))",
+relevance:0},{className:"number",begin:"\\b((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(L|u|U|Lu|LU|uL|UL)?",relevance:0},{className:"string",begin:"'(\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};|.)",end:"'",illegal:"."},{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}});b.registerLanguage("markdown",
+function(a){return{aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$"},{begin:"^.+?\\n[=-]{2,}$"}]},{begin:"<",end:">",subLanguage:"xml",relevance:0},{className:"bullet",begin:"^([*+-]|(\\d+\\.))\\s+"},{className:"strong",begin:"[*_]{2}.+?[*_]{2}"},{className:"emphasis",variants:[{begin:"\\*.+?\\*"},{begin:"_.+?_",relevance:0}]},{className:"quote",begin:"^>\\s+",end:"$"},{className:"code",variants:[{begin:"^```w*s*$",end:"^```s*$"},{begin:"`.+?`"},{begin:"^( {4}|\t)",
+end:"$",relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},{begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",
+begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}});b.registerLanguage("dart",function(a){var b={className:"subst",begin:"\\$\\{",end:"}",keywords:"true false null this is new super"},d={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[a.BACKSLASH_ESCAPE,b]},{begin:'"""',end:'"""',contains:[a.BACKSLASH_ESCAPE,b]},{begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
+b]},{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]}]};b.contains=[a.C_NUMBER_MODE,d];return{keywords:{keyword:"assert async await break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch sync this throw true try var void while with yield abstract as dynamic export external factory get implements import library operator part set static typedef",built_in:"print Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double int num document window querySelector querySelectorAll Element ElementList"},
+contains:[d,a.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown"}),a.COMMENT("///","$",{subLanguage:"markdown"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",
+relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",
+end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",
+contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});b.registerLanguage("ruby",function(a){var b={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},d={className:"doctag",begin:"@[A-Za-z]+"},e={begin:"#<",end:">"};d=[a.COMMENT("#","$",{contains:[d]}),a.COMMENT("^\\=begin",
+"^\\=end",{contains:[d],relevance:10}),a.COMMENT("^__END__","\\n$")];var c={className:"subst",begin:"#\\{",end:"}",keywords:b},g={className:"string",contains:[a.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},
+{begin:/<<(-?)\w+$/,end:/^\s*\w+$/}]},k={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:b};a=[g,e,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+a.IDENT_RE+"::)?"+a.IDENT_RE}]}].concat(d)},{className:"function",beginKeywords:"def",end:"$|;",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}),
+k].concat(d)},{begin:a.IDENT_RE+"::"},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[g,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:b},{begin:"("+a.RE_STARTERS_RE+
+"|unless)\\s*",keywords:"unless",contains:[e,{className:"regexp",contains:[a.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(d),relevance:0}].concat(d);c.contains=a;k.contains=a;return{aliases:["rb","gemspec","podspec","thor","irb"],keywords:b,illegal:/\/\*/,contains:d.concat([{begin:/^\s*=>/,starts:{end:"$",contains:a}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",
+starts:{end:"$",contains:a}}]).concat(a)}});b.registerLanguage("erb",function(a){return{subLanguage:"xml",contains:[a.COMMENT("<%#","%>"),{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
 literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],keywords:b,illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[a.QUOTE_STRING_MODE,{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"`"}]},{className:"number",variants:[{begin:a.C_NUMBER_RE+"[dflsi]",relevance:1},a.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",end:/\s*\{/,excludeEnd:!0,
-contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},e={className:"meta",begin:"{-#",end:"#-}"},h={className:"meta",begin:"^#",end:"$"},c={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},f={begin:"\\(",end:"\\)",illegal:'"',contains:[e,h,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
-b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[f,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[f,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
-end:"where",keywords:"class family instance where",contains:[c,f,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[e,c,f,{begin:"{",end:"}",contains:f.contains},b]},{beginKeywords:"default",end:"$",contains:[c,f,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[c,a.QUOTE_STRING_MODE,
-b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},e,h,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,c,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",
+contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},d={className:"meta",begin:"{-#",end:"#-}"},e={className:"meta",begin:"^#",end:"$"},c={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},g={begin:"\\(",end:"\\)",illegal:'"',contains:[d,e,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
+b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[g,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[g,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
+end:"where",keywords:"class family instance where",contains:[c,g,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[d,c,g,{begin:"{",end:"}",contains:g.contains},b]},{beginKeywords:"default",end:"$",contains:[c,g,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[c,a.QUOTE_STRING_MODE,
+b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},d,e,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,c,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",
 illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"([\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(<[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(\\s*,\\s*[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*)*>)?\\s+)+"+
 a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
 contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,
 a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("javascript",function(a){var b={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
 literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},
-e={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},h={className:"subst",begin:"\\$\\{",end:"\\}",keywords:b,contains:[]},c={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,h]};h.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,e,a.REGEXP_MODE];h=h.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:b,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},
-{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z$_][0-9A-Za-z$_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z$_][0-9A-Za-z$_]*)\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:h}]}]},{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:[{begin:/<\w+\s*\/>/,skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),
-{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:h}],illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},e=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],h={end:",",endsWithParent:!0,excludeEnd:!0,
-contains:e,keywords:b},c={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(h,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(h)],illegal:"\\S"};e.splice(e.length,0,c,a);return{contains:e,keywords:b,illegal:"\\S"}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
-e={begin:"->{",end:"}"},h={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},c=[a.BACKSLASH_ESCAPE,b,h];a=[h,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),e,{className:"string",contains:c,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
+d={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},e={className:"subst",begin:"\\$\\{",end:"\\}",keywords:b,contains:[]},c={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,e]};e.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,d,a.REGEXP_MODE];e=e.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:b,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},
+{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z$_][0-9A-Za-z$_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z$_][0-9A-Za-z$_]*)\\s*=>",
+returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:e}]}]},{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:[{begin:/<\w+\s*\/>/,skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),
+{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:e}],illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},d=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],e={end:",",endsWithParent:!0,excludeEnd:!0,
+contains:d,keywords:b},c={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(e,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(e)],illegal:"\\S"};d.splice(d.length,0,c,a);return{contains:d,keywords:b,illegal:"\\S"}});b.registerLanguage("kotlin",function(a){var b={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit initinterface annotation data sealed internal infix operator out by constructor super trait volatile transient native default",
+built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},d={className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"@"},e={className:"subst",begin:"\\${",end:"}",contains:[a.APOS_STRING_MODE,a.C_NUMBER_MODE]},c={className:"variable",begin:"\\$"+a.UNDERSCORE_IDENT_RE};e={className:"string",variants:[{begin:'"""',end:'"""',contains:[c,e]},{begin:"'",end:"'",illegal:/\n/,contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[a.BACKSLASH_ESCAPE,c,
+e]}]};c={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+a.UNDERSCORE_IDENT_RE+")?"};var g={className:"meta",begin:"@"+a.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[a.inherit(e,{className:"meta-string"})]}]};return{keywords:b,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"keyword",begin:/\b(break|continue|return|this)\b/,
+starts:{contains:[{className:"symbol",begin:/@\w+/}]}},d,c,g,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:b,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,
+endsWithParent:!0,contains:[{className:"type",begin:a.UNDERSCORE_IDENT_RE},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],relevance:0},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,c,g,e,a.C_NUMBER_MODE]},a.C_BLOCK_COMMENT_MODE]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},a.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0,excludeEnd:!0,
+relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},c,g]},e,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},a.C_NUMBER_MODE]}});b.registerLanguage("lisp",function(a){var b={className:"literal",begin:"\\b(t{1}|nil)\\b"},d={className:"number",variants:[{begin:"(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},
+{begin:"#(c|C)\\((\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)? +(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",end:"\\)"}]},e=a.inherit(a.QUOTE_STRING_MODE,{illegal:null});a=a.COMMENT(";","$",{relevance:0});var c={begin:"\\*",end:"\\*"},g={className:"symbol",begin:"[:&][a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},k={begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",relevance:0},
+l={contains:[d,e,c,g,{begin:"\\(",end:"\\)",contains:["self",b,e,d,k]},k],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},m={variants:[{begin:"'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"#'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*(::[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*)*"}]},n={begin:"\\(\\s*",end:"\\)"},h={endsWithParent:!0,
+relevance:0};n.contains=[{className:"name",variants:[{begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"\\|[^]*?\\|"}]},h];h.contains=[l,m,n,b,d,e,a,c,g,{begin:"\\|[^]*?\\|"},k];return{illegal:/\S/,contains:[d,{className:"meta",begin:"^#!",end:"$"},b,e,a,l,m,n,k]}});b.registerLanguage("lua",function(a){var b={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},d=[a.COMMENT("--(?!\\[=*\\[)","$"),a.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[b],relevance:10})];
+return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstringmodule next pairs pcall print rawequal rawget rawset require select setfenvsetmetatable tonumber tostring type unpack xpcall arg selfcoroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},
+contains:d.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:d}].concat(d)},a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[b],relevance:5}])}});b.registerLanguage("objectivec",function(a){var b=/[a-zA-Z@][a-zA-Z0-9_]*/;return{aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
+literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},lexemes:b,illegal:"</",contains:[{className:"built_in",begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"}]},
+{className:"meta",begin:"#",end:"$",contains:[{className:"meta-string",variants:[{begin:'"',end:'"'},{begin:"<",end:">"}]}]},{className:"class",begin:"(@interface|@class|@protocol|@implementation)\\b",end:"({|$)",excludeEnd:!0,keywords:"@interface @class @protocol @implementation",lexemes:b,contains:[a.UNDERSCORE_TITLE_MODE]},{begin:"\\."+a.UNDERSCORE_IDENT_RE,relevance:0}]}});b.registerLanguage("ocaml",function(a){return{aliases:["ml"],keywords:{keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
+built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",literal:"true false"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},a.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0},
+a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
+d={begin:"->{",end:"}"},e={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},c=[a.BACKSLASH_ESCAPE,b,e];a=[e,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),d,{className:"string",contains:c,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
 end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+a.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",
 relevance:0,contains:[a.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[a.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[a.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];b.contains=
-a;e.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
-contains:a}});b.registerLanguage("prolog",function(a){var b={begin:/\(/,end:/\)/,relevance:0},e={begin:/\[/,end:/\]/};a=[{begin:/[a-z][A-Za-z0-9_]*/,relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{begin:/_[A-Za-z0-9_]*/}],relevance:0},b,{begin:/:-/},e,{className:"comment",begin:/%/,end:/$/,contains:[a.PHRASAL_WORDS_MODE]},a.C_BLOCK_COMMENT_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{className:"string",begin:/`/,end:/`/,contains:[a.BACKSLASH_ESCAPE]},{className:"string",begin:/0\'(\\\'|.)/},
-{className:"string",begin:/0\'\\s/},a.C_NUMBER_MODE];b.contains=a;e.contains=a;return{contains:a.concat([{begin:/\.$/}])}});b.registerLanguage("python",function(a){var b={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},e={className:"meta",begin:/^(>>>|\.\.\.) /},h={className:"subst",begin:/\{/,end:/\}/,
-keywords:b,illegal:/#/},c={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[e],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[e],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[e,h]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[e,h]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[h]},{begin:/(fr|rf|f)"/,end:/"/,
-contains:[h]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},f={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]},k={className:"params",begin:/\(/,end:/\)/,contains:["self",e,f,c]};h.contains=[c,f,e];return{aliases:["py","gyp"],keywords:b,illegal:/(<\/|->|\?)|=>/,contains:[e,f,c,a.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,
-contains:[a.UNDERSCORE_TITLE_MODE,k,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},e={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},h={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",
-keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
-b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},e,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[h]},{className:"class",beginKeywords:"class object trait type",end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-relevance:0,contains:[e]},h]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("sql",function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment",
-end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
+a;d.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
+contains:a}});b.registerLanguage("php",function(a){var b={begin:"\\$+[a-zA-Z_\u007f-\u00ff][a-zA-Z0-9_\u007f-\u00ff]*"},d={className:"meta",begin:/<\?(php)?|\?>/},e={className:"string",contains:[a.BACKSLASH_ESCAPE,d],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},c={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{aliases:["php3","php4","php5","php6"],case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",
+contains:[a.HASH_COMMENT_MODE,a.COMMENT("//","$",{contains:[d]}),a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[a.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},d,{className:"keyword",begin:/\$this\b/},b,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},
+{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:["self",b,a.C_BLOCK_COMMENT_MODE,e,c]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},
+{begin:"=>"},e,c]}});b.registerLanguage("protobuf",function(a){return{keywords:{keyword:"package import option optional required repeated group",built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},
+{className:"function",beginKeywords:"rpc",end:/;/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}});b.registerLanguage("puppet",function(a){var b=a.COMMENT("#","$"),d=a.inherit(a.TITLE_MODE,{begin:"([A-Za-z_]|::)(\\w|::)*"}),e={className:"variable",begin:"\\$([A-Za-z_]|::)(\\w|::)*"},c={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}]};return{aliases:["pp"],contains:[b,e,c,{beginKeywords:"class",end:"\\{|;",
+illegal:/=/,contains:[d,b]},{beginKeywords:"define",end:/\{/,contains:[{className:"section",begin:a.IDENT_RE,endsParent:!0}]},{begin:a.IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\S/,contains:[{className:"keyword",begin:a.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",
+built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"},
+relevance:0,contains:[c,b,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",contains:[{className:"attr",begin:a.IDENT_RE}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},e]}],relevance:0}]}});b.registerLanguage("python",function(a){var b={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",
+built_in:"Ellipsis NotImplemented"},d={className:"meta",begin:/^(>>>|\.\.\.) /},e={className:"subst",begin:/\{/,end:/\}/,keywords:b,illegal:/#/},c={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[d],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[d],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[d,e]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[d,e]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},
+{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[e]},{begin:/(fr|rf|f)"/,end:/"/,contains:[e]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},g={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]},k={className:"params",begin:/\(/,end:/\)/,contains:["self",d,g,c]};e.contains=[c,g,d];return{aliases:["py","gyp"],keywords:b,illegal:/(<\/|->|\?)|=>/,contains:[d,g,c,a.HASH_COMMENT_MODE,
+{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[a.UNDERSCORE_TITLE_MODE,k,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("rust",function(a){return{aliases:["rs"],keywords:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self Self sizeof static struct super trait true type typeof unsafe unsized use virtual while where yield move default",
+literal:"true false Some None Ok Err",built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"},
+lexemes:a.IDENT_RE+"!?",illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.COMMENT("/\\*","\\*/",{contains:["self"]}),a.inherit(a.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{begin:"\\b0b([01_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0o([0-7_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0x([A-Fa-f0-9_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},
+{begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([ui](8|16|32|64|128|size)|f(32|64))?"}],relevance:0},{className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#\\!?\\[",end:"\\]",contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",beginKeywords:"type",end:";",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"\\S"},{className:"class",beginKeywords:"trait enum struct union",end:"{",
+contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"},{begin:a.IDENT_RE+"::",keywords:{built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"}},
+{begin:"->"}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},d={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},e={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},d,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[e]},{className:"class",beginKeywords:"class object trait type",
+end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},e]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("shell",function(a){return{aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[\\w\\d\\[\\]()@-]*[>%$#]",starts:{end:"$",subLanguage:"bash"}}]}});b.registerLanguage("sql",
+function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment",end:/;/,endsWithParent:!0,
+lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
 literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text varchar varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}]},{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b]},
-a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",
-end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript",
-"javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});return b});
+a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("swift",function(a){var b={keyword:"__COLUMN__ __FILE__ __FUNCTION__ __LINE__ as as! as? associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",
+literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},
+d=a.COMMENT("/\\*","\\*/",{contains:["self"]}),e={className:"subst",begin:/\\\(/,end:"\\)",keywords:b,contains:[]},c={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0},g=a.inherit(a.QUOTE_STRING_MODE,{contains:[e,a.BACKSLASH_ESCAPE]});e.contains=[c];return{keywords:b,contains:[g,a.C_LINE_COMMENT_MODE,d,{className:"type",begin:"\\b[A-Z][\\w\u00c0-\u02b8']*",relevance:0},c,{className:"function",beginKeywords:"func",end:"{",
+excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin:/</,end:/>/},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,contains:["self",c,g,a.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:b,end:"\\{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain)"},
+{beginKeywords:"import",end:/$/,contains:[a.C_LINE_COMMENT_MODE,d]}]}});b.registerLanguage("typescript",function(a){var b={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract as from extends async await",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void Promise"};
+return{aliases:["ts"],keywords:b,contains:[{className:"meta",begin:/^\s*['"]use strict['"]/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+a.IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:a.IDENT_RE},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:["self",a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}]}],relevance:0},{className:"function",begin:"function",end:/[\{;]/,excludeEnd:!0,keywords:b,contains:["self",a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),
+{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0,contains:["self",{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},
+{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+a.IDENT_RE,relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("yaml",function(a){var b={className:"attr",variants:[{begin:"^[ \\-]*[a-zA-Z_][\\w\\-]*:"},{begin:'^[ \\-]*"[a-zA-Z_][\\w\\-]*":'},{begin:"^[ \\-]*'[a-zA-Z_][\\w\\-]*':"}]},d={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[a.BACKSLASH_ESCAPE,{className:"template-variable",
+variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[b,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:d.contains,end:b.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!!"+a.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+a.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+
+a.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},a.HASH_COMMENT_MODE,{beginKeywords:"true false yes no null",keywords:{literal:"true false yes no null"}},a.C_NUMBER_MODE,d]}});return b});
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 6e8fcd8..8e9fbc5 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -4,7 +4,7 @@
     name = "fluent-hc",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@fluent_hc//jar"],
+    exports = ["@fluent-hc//jar"],
     runtime_deps = [":httpclient"],
 )
 
@@ -43,5 +43,5 @@
 java_library(
     name = "httpcore-nio",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@httpcore_nio//jar"],
+    exports = ["@httpcore-nio//jar"],
 )
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index c01890d..5c15193 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -5,5 +5,5 @@
 java_library(
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jackson_core//jar"],
+    exports = ["@jackson-core//jar"],
 )
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index c6ba9c8..c5f1da8 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -2,7 +2,7 @@
     name = "servlet",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jetty_servlet//jar"],
+    exports = ["@jetty-servlet//jar"],
     runtime_deps = [":security"],
 )
 
@@ -10,7 +10,7 @@
     name = "security",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jetty_security//jar"],
+    exports = ["@jetty-security//jar"],
     runtime_deps = [":server"],
 )
 
@@ -18,7 +18,7 @@
     name = "servlets",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jetty_servlets//jar"],
+    exports = ["@jetty-servlets//jar"],
 )
 
 java_library(
@@ -28,7 +28,7 @@
     exports = [
         ":continuation",
         ":http",
-        "@jetty_server//jar",
+        "@jetty-server//jar",
     ],
 )
 
@@ -39,7 +39,7 @@
     exports = [
         ":continuation",
         ":http",
-        "@jetty_jmx//jar",
+        "@jetty-jmx//jar",
     ],
 )
 
@@ -47,7 +47,7 @@
     name = "continuation",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jetty_continuation//jar"],
+    exports = ["@jetty-continuation//jar"],
 )
 
 java_library(
@@ -56,7 +56,7 @@
     visibility = ["//visibility:public"],
     exports = [
         ":io",
-        "@jetty_http//jar",
+        "@jetty-http//jar",
     ],
 )
 
@@ -65,12 +65,12 @@
     data = ["//lib:LICENSE-Apache2.0"],
     exports = [
         ":util",
-        "@jetty_io//jar",
+        "@jetty-io//jar",
     ],
 )
 
 java_library(
     name = "util",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jetty_util//jar"],
+    exports = ["@jetty-util//jar"],
 )
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 749fec9..816c3a3 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,66 +1,80 @@
-load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_CENTRAL", "MAVEN_LOCAL", "maven_jar")
 
-_JGIT_VERS = "4.11.0.201803080745-r.93-gcbb2e65db"
+_JGIT_VERS = "5.1.1.201809181055-r"
 
-_DOC_VERS = "4.11.0.201803080745-r"  # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = GERRIT  # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
 LOCAL_JGIT_REPO = ""
 
 def jgit_repos():
-  if LOCAL_JGIT_REPO:
-    native.local_repository(
-        name = "jgit",
-        path = LOCAL_JGIT_REPO,
+    if LOCAL_JGIT_REPO:
+        native.local_repository(
+            name = "jgit",
+            path = LOCAL_JGIT_REPO,
+        )
+        jgit_maven_repos_dev()
+    else:
+        jgit_maven_repos()
+
+def jgit_maven_repos_dev():
+    # Transitive dependencies from JGit's WORKSPACE.
+    maven_jar(
+        name = "hamcrest-library",
+        artifact = "org.hamcrest:hamcrest-library:1.3",
+        sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
     )
-  else:
-    jgit_maven_repos()
+    maven_jar(
+        name = "jzlib",
+        artifact = "com.jcraft:jzlib:1.1.1",
+        sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
+    )
 
 def jgit_maven_repos():
     maven_jar(
-        name = "jgit_lib",
+        name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "265a39c017ecfeed7e992b6aaa336e515bf6e157",
-        src_sha1 = "e9d801e17afe71cdd5ade84ab41ff0110c3f28fd",
+        sha1 = "64dfe41b3c152bb9b7158b214e28467cb1217153",
+        src_sha1 = "ff6ab018897cf4213b905e156ac5930bad2bdff1",
         unsign = True,
     )
     maven_jar(
-        name = "jgit_servlet",
+        name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "0d68f62286b5db759fdbeb122c789db1f833a06a",
+        sha1 = "22fd6827fbb6135efd813271185a91f8615538eb",
         unsign = True,
     )
     maven_jar(
-        name = "jgit_archive",
+        name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "4cc3ed2c42ee63593fd1b16215fcf13eeefb833e",
+        sha1 = "bfbbdd6aa1893db14f346913aad3f9898b2fe01d",
     )
     maven_jar(
-        name = "jgit_junit",
+        name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "6f1bcc9ac22b31b5a6e1e68c08283850108b900c",
+        sha1 = "6de6de74053d7c28100fe128255d7382a939fe99",
         unsign = True,
     )
 
 def jgit_dep(name):
-  mapping = {
-      "@jgit_junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
-      "@jgit_lib//jar:src": "@jgit//org.eclipse.jgit:libjgit-src.jar",
-      "@jgit_lib//jar": "@jgit//org.eclipse.jgit:jgit",
-      "@jgit_servlet//jar":"@jgit//org.eclipse.jgit.http.server:jgit-servlet",
-      "@jgit_archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
-  }
+    mapping = {
+        "@jgit-junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
+        "@jgit-lib//jar:src": "@jgit//org.eclipse.jgit:libjgit-src.jar",
+        "@jgit-lib//jar": "@jgit//org.eclipse.jgit:jgit",
+        "@jgit-servlet//jar": "@jgit//org.eclipse.jgit.http.server:jgit-servlet",
+        "@jgit-archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
+    }
 
-  if LOCAL_JGIT_REPO:
-    return mapping[name]
-  else:
-    return name
+    if LOCAL_JGIT_REPO:
+        return mapping[name]
+    else:
+        return name
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
index 198ff25..2742623 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -4,6 +4,6 @@
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit_archive//jar")],
+    exports = [jgit_dep("@jgit-archive//jar")],
     runtime_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 6b5bf78..001ad8b 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -4,6 +4,6 @@
     name = "jgit-servlet",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit_servlet//jar")],
+    exports = [jgit_dep("@jgit-servlet//jar")],
     runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index ba6c42f..85e9167 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -5,6 +5,6 @@
     testonly = 1,
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit_junit//jar")],
+    exports = [jgit_dep("@jgit-junit//jar")],
     runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index caf8eec..d61ac93 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -4,7 +4,7 @@
     name = "jgit",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = [jgit_dep("@jgit_lib//jar")],
+    exports = [jgit_dep("@jgit-lib//jar")],
     runtime_deps = [
         ":javaewah",
         "//lib/log:api",
@@ -13,7 +13,7 @@
 
 alias(
     name = "jgit-source",
-    actual = jgit_dep("@jgit_lib//jar:src"),
+    actual = jgit_dep("@jgit-lib//jar:src"),
     visibility = ["//visibility:public"],
 )
 
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 6b4e003..75c8277 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -7,138 +7,165 @@
 load("//tools/bzl:js.bzl", "bower_archive")
 
 def load_bower_archives():
-  bower_archive(
-    name = "accessibility-developer-tools",
-    package = "accessibility-developer-tools",
-    version = "2.12.0",
-    sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49")
-  bower_archive(
-    name = "async",
-    package = "async",
-    version = "1.5.2",
-    sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508")
-  bower_archive(
-    name = "chai",
-    package = "chai",
-    version = "3.5.0",
-    sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa")
-  bower_archive(
-    name = "font-roboto",
-    package = "PolymerElements/font-roboto",
-    version = "1.1.0",
-    sha1 = "ab4218d87b9ce569d6282b01f7642e551879c3d5")
-  bower_archive(
-    name = "iron-a11y-announcer",
-    package = "PolymerElements/iron-a11y-announcer",
-    version = "1.0.6",
-    sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc")
-  bower_archive(
-    name = "iron-a11y-keys-behavior",
-    package = "PolymerElements/iron-a11y-keys-behavior",
-    version = "1.1.9",
-    sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465")
-  bower_archive(
-    name = "iron-behaviors",
-    package = "PolymerElements/iron-behaviors",
-    version = "1.0.18",
-    sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18")
-  bower_archive(
-    name = "iron-checked-element-behavior",
-    package = "PolymerElements/iron-checked-element-behavior",
-    version = "1.0.6",
-    sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370")
-  bower_archive(
-    name = "iron-fit-behavior",
-    package = "PolymerElements/iron-fit-behavior",
-    version = "1.2.7",
-    sha1 = "01c485fbf898307029bbb72ac7e132db1570a842")
-  bower_archive(
-    name = "iron-flex-layout",
-    package = "PolymerElements/iron-flex-layout",
-    version = "1.3.9",
-    sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796")
-  bower_archive(
-    name = "iron-form-element-behavior",
-    package = "PolymerElements/iron-form-element-behavior",
-    version = "1.0.7",
-    sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12")
-  bower_archive(
-    name = "iron-menu-behavior",
-    package = "PolymerElements/iron-menu-behavior",
-    version = "2.1.1",
-    sha1 = "1504997f6eb9aec490b855dadee473cac064f38c")
-  bower_archive(
-    name = "iron-meta",
-    package = "PolymerElements/iron-meta",
-    version = "1.1.3",
-    sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041")
-  bower_archive(
-    name = "iron-resizable-behavior",
-    package = "polymerelements/iron-resizable-behavior",
-    version = "1.0.6",
-    sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e")
-  bower_archive(
-    name = "iron-validatable-behavior",
-    package = "PolymerElements/iron-validatable-behavior",
-    version = "1.1.2",
-    sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be")
-  bower_archive(
-    name = "lodash",
-    package = "lodash",
-    version = "3.10.1",
-    sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a")
-  bower_archive(
-    name = "mocha",
-    package = "mocha",
-    version = "3.5.3",
-    sha1 = "c14f149821e4e96241b20f85134aa757b73038f1")
-  bower_archive(
-    name = "neon-animation",
-    package = "polymerelements/neon-animation",
-    version = "1.2.5",
-    sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8")
-  bower_archive(
-    name = "paper-behaviors",
-    package = "PolymerElements/paper-behaviors",
-    version = "1.0.13",
-    sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7")
-  bower_archive(
-    name = "paper-icon-button",
-    package = "PolymerElements/paper-icon-button",
-    version = "2.2.0",
-    sha1 = "9525e76ef433428bb9d6ec4fa65c4ef83156a803")
-  bower_archive(
-    name = "paper-ripple",
-    package = "PolymerElements/paper-ripple",
-    version = "1.0.10",
-    sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893")
-  bower_archive(
-    name = "paper-styles",
-    package = "PolymerElements/paper-styles",
-    version = "1.3.1",
-    sha1 = "4ee9c692366949a754e0e39f8031aa60ce66f24d")
-  bower_archive(
-    name = "sinon-chai",
-    package = "sinon-chai",
-    version = "2.14.0",
-    sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc")
-  bower_archive(
-    name = "sinonjs",
-    package = "sinonjs",
-    version = "1.17.1",
-    sha1 = "a26a6aab7358807de52ba738770f6ac709afd240")
-  bower_archive(
-    name = "stacky",
-    package = "stacky",
-    version = "1.3.2",
-    sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb")
-  bower_archive(
-    name = "web-animations-js",
-    package = "web-animations/web-animations-js",
-    version = "2.3.1",
-    sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e")
-  bower_archive(
-    name = "webcomponentsjs",
-    package = "webcomponents/webcomponentsjs",
-    version = "0.7.24",
-    sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c")
+    bower_archive(
+        name = "accessibility-developer-tools",
+        package = "accessibility-developer-tools",
+        version = "2.12.0",
+        sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49",
+    )
+    bower_archive(
+        name = "async",
+        package = "async",
+        version = "1.5.2",
+        sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508",
+    )
+    bower_archive(
+        name = "chai",
+        package = "chai",
+        version = "3.5.0",
+        sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa",
+    )
+    bower_archive(
+        name = "font-roboto",
+        package = "PolymerElements/font-roboto",
+        version = "1.1.0",
+        sha1 = "ab4218d87b9ce569d6282b01f7642e551879c3d5",
+    )
+    bower_archive(
+        name = "iron-a11y-announcer",
+        package = "PolymerElements/iron-a11y-announcer",
+        version = "1.0.6",
+        sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc",
+    )
+    bower_archive(
+        name = "iron-a11y-keys-behavior",
+        package = "PolymerElements/iron-a11y-keys-behavior",
+        version = "1.1.9",
+        sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465",
+    )
+    bower_archive(
+        name = "iron-behaviors",
+        package = "PolymerElements/iron-behaviors",
+        version = "1.0.18",
+        sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18",
+    )
+    bower_archive(
+        name = "iron-checked-element-behavior",
+        package = "PolymerElements/iron-checked-element-behavior",
+        version = "1.0.6",
+        sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370",
+    )
+    bower_archive(
+        name = "iron-fit-behavior",
+        package = "PolymerElements/iron-fit-behavior",
+        version = "1.2.7",
+        sha1 = "01c485fbf898307029bbb72ac7e132db1570a842",
+    )
+    bower_archive(
+        name = "iron-flex-layout",
+        package = "PolymerElements/iron-flex-layout",
+        version = "1.3.9",
+        sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796",
+    )
+    bower_archive(
+        name = "iron-form-element-behavior",
+        package = "PolymerElements/iron-form-element-behavior",
+        version = "1.0.7",
+        sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12",
+    )
+    bower_archive(
+        name = "iron-menu-behavior",
+        package = "PolymerElements/iron-menu-behavior",
+        version = "2.1.1",
+        sha1 = "1504997f6eb9aec490b855dadee473cac064f38c",
+    )
+    bower_archive(
+        name = "iron-meta",
+        package = "PolymerElements/iron-meta",
+        version = "1.1.3",
+        sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041",
+    )
+    bower_archive(
+        name = "iron-resizable-behavior",
+        package = "polymerelements/iron-resizable-behavior",
+        version = "1.0.6",
+        sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e",
+    )
+    bower_archive(
+        name = "iron-validatable-behavior",
+        package = "PolymerElements/iron-validatable-behavior",
+        version = "1.1.2",
+        sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be",
+    )
+    bower_archive(
+        name = "lodash",
+        package = "lodash",
+        version = "3.10.1",
+        sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a",
+    )
+    bower_archive(
+        name = "mocha",
+        package = "mocha",
+        version = "3.5.3",
+        sha1 = "c14f149821e4e96241b20f85134aa757b73038f1",
+    )
+    bower_archive(
+        name = "neon-animation",
+        package = "polymerelements/neon-animation",
+        version = "1.2.5",
+        sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8",
+    )
+    bower_archive(
+        name = "paper-behaviors",
+        package = "PolymerElements/paper-behaviors",
+        version = "1.0.13",
+        sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7",
+    )
+    bower_archive(
+        name = "paper-icon-button",
+        package = "PolymerElements/paper-icon-button",
+        version = "2.2.0",
+        sha1 = "9525e76ef433428bb9d6ec4fa65c4ef83156a803",
+    )
+    bower_archive(
+        name = "paper-ripple",
+        package = "PolymerElements/paper-ripple",
+        version = "1.0.10",
+        sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893",
+    )
+    bower_archive(
+        name = "paper-styles",
+        package = "PolymerElements/paper-styles",
+        version = "1.3.1",
+        sha1 = "4ee9c692366949a754e0e39f8031aa60ce66f24d",
+    )
+    bower_archive(
+        name = "sinon-chai",
+        package = "sinon-chai",
+        version = "2.14.0",
+        sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc",
+    )
+    bower_archive(
+        name = "sinonjs",
+        package = "sinonjs",
+        version = "1.17.1",
+        sha1 = "a26a6aab7358807de52ba738770f6ac709afd240",
+    )
+    bower_archive(
+        name = "stacky",
+        package = "stacky",
+        version = "1.3.2",
+        sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb",
+    )
+    bower_archive(
+        name = "web-animations-js",
+        package = "web-animations/web-animations-js",
+        version = "2.3.1",
+        sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e",
+    )
+    bower_archive(
+        name = "webcomponentsjs",
+        package = "webcomponents/webcomponentsjs",
+        version = "0.7.24",
+        sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c",
+    )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index dc16ccf..a540828 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -7,377 +7,377 @@
 load("//tools/bzl:js.bzl", "bower_component")
 
 def define_bower_components():
-  bower_component(
-    name = "accessibility-developer-tools",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "async",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "chai",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "es6-promise",
-    license = "//lib:LICENSE-es6-promise",
-    seed = True,
-  )
-  bower_component(
-    name = "fetch",
-    license = "//lib:LICENSE-fetch",
-    seed = True,
-  )
-  bower_component(
-    name = "font-roboto",
-    license = "//lib:LICENSE-polymer",
-  )
-  bower_component(
-    name = "iron-a11y-announcer",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-a11y-keys-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-autogrow-textarea",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-behaviors",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-checked-element-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-form-element-behavior",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-dropdown",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-overlay-behavior",
-      ":iron-resizable-behavior",
-      ":neon-animation",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-fit-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-flex-layout",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-form-element-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-icon",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-flex-layout",
-      ":iron-meta",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-iconset-svg",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-input",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-announcer",
-      ":iron-validatable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-menu-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-flex-layout",
-      ":iron-selector",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "iron-meta",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-overlay-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-fit-behavior",
-      ":iron-resizable-behavior",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-resizable-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-  )
-  bower_component(
-    name = "iron-selector",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-test-helpers",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "iron-validatable-behavior",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "lodash",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "mocha",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "moment",
-    license = "//lib:LICENSE-moment",
-    seed = True,
-  )
-  bower_component(
-    name = "neon-animation",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-meta",
-      ":iron-resizable-behavior",
-      ":iron-selector",
-      ":polymer",
-      ":web-animations-js",
-    ],
-  )
-  bower_component(
-    name = "page",
-    license = "//lib:LICENSE-page.js",
-    seed = True,
-  )
-  bower_component(
-    name = "paper-behaviors",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-checked-element-behavior",
-      ":paper-ripple",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-flex-layout",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-icon-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-icon",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-input",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":iron-autogrow-textarea",
-      ":iron-behaviors",
-      ":iron-form-element-behavior",
-      ":iron-input",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-item",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-listbox",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-menu-behavior",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-ripple",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-a11y-keys-behavior",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-styles",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":font-roboto",
-      ":iron-flex-layout",
-      ":polymer",
-    ],
-  )
-  bower_component(
-    name = "paper-tabs",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-behaviors",
-      ":iron-flex-layout",
-      ":iron-icon",
-      ":iron-iconset-svg",
-      ":iron-menu-behavior",
-      ":iron-resizable-behavior",
-      ":paper-behaviors",
-      ":paper-icon-button",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "paper-toggle-button",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":iron-checked-element-behavior",
-      ":paper-behaviors",
-      ":paper-styles",
-      ":polymer",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "polymer-resin",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":polymer",
-      ":webcomponentsjs",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "polymer",
-    license = "//lib:LICENSE-polymer",
-    deps = [ ":webcomponentsjs" ],
-    seed = True,
-  )
-  bower_component(
-    name = "promise-polyfill",
-    license = "//lib:LICENSE-promise-polyfill",
-    deps = [ ":polymer" ],
-    seed = True,
-  )
-  bower_component(
-    name = "sinon-chai",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "sinonjs",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "stacky",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-  )
-  bower_component(
-    name = "test-fixture",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    seed = True,
-  )
-  bower_component(
-    name = "web-animations-js",
-    license = "//lib:LICENSE-Apache2.0",
-  )
-  bower_component(
-    name = "web-component-tester",
-    license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    deps = [
-      ":accessibility-developer-tools",
-      ":async",
-      ":chai",
-      ":lodash",
-      ":mocha",
-      ":sinon-chai",
-      ":sinonjs",
-      ":stacky",
-      ":test-fixture",
-    ],
-    seed = True,
-  )
-  bower_component(
-    name = "webcomponentsjs",
-    license = "//lib:LICENSE-polymer",
-  )
+    bower_component(
+        name = "accessibility-developer-tools",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "async",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "es6-promise",
+        license = "//lib:LICENSE-es6-promise",
+        seed = True,
+    )
+    bower_component(
+        name = "fetch",
+        license = "//lib:LICENSE-fetch",
+        seed = True,
+    )
+    bower_component(
+        name = "font-roboto",
+        license = "//lib:LICENSE-polymer",
+    )
+    bower_component(
+        name = "iron-a11y-announcer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-a11y-keys-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-autogrow-textarea",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-checked-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-form-element-behavior",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-dropdown",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-overlay-behavior",
+            ":iron-resizable-behavior",
+            ":neon-animation",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-fit-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-flex-layout",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-form-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-icon",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-iconset-svg",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-announcer",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-menu-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-flex-layout",
+            ":iron-selector",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-meta",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-overlay-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-fit-behavior",
+            ":iron-resizable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-resizable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-selector",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-test-helpers",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-validatable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "lodash",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "mocha",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "moment",
+        license = "//lib:LICENSE-moment",
+        seed = True,
+    )
+    bower_component(
+        name = "neon-animation",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":iron-resizable-behavior",
+            ":iron-selector",
+            ":polymer",
+            ":web-animations-js",
+        ],
+    )
+    bower_component(
+        name = "page",
+        license = "//lib:LICENSE-page.js",
+        seed = True,
+    )
+    bower_component(
+        name = "paper-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-checked-element-behavior",
+            ":paper-ripple",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-icon-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-icon",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-autogrow-textarea",
+            ":iron-behaviors",
+            ":iron-form-element-behavior",
+            ":iron-input",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-item",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-listbox",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-menu-behavior",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-ripple",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-styles",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":font-roboto",
+            ":iron-flex-layout",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-tabs",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":iron-icon",
+            ":iron-iconset-svg",
+            ":iron-menu-behavior",
+            ":iron-resizable-behavior",
+            ":paper-behaviors",
+            ":paper-icon-button",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-toggle-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-checked-element-behavior",
+            ":paper-behaviors",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "polymer-resin",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":polymer",
+            ":webcomponentsjs",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "polymer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":webcomponentsjs"],
+        seed = True,
+    )
+    bower_component(
+        name = "promise-polyfill",
+        license = "//lib:LICENSE-promise-polyfill",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "sinon-chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "sinonjs",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "stacky",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "test-fixture",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        seed = True,
+    )
+    bower_component(
+        name = "web-animations-js",
+        license = "//lib:LICENSE-Apache2.0",
+    )
+    bower_component(
+        name = "web-component-tester",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [
+            ":accessibility-developer-tools",
+            ":async",
+            ":chai",
+            ":lodash",
+            ":mocha",
+            ":sinon-chai",
+            ":sinonjs",
+            ":stacky",
+            ":test-fixture",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "webcomponentsjs",
+        license = "//lib:LICENSE-polymer",
+    )
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
index 0fd575d..92f44bd 100644
--- a/lib/js/npm.bzl
+++ b/lib/js/npm.bzl
@@ -1,11 +1,11 @@
 NPM_VERSIONS = {
     "bower": "1.8.2",
     "crisper": "2.0.2",
-    "vulcanize": "1.14.8",
+    "polymer-bundler": "4.0.2",
 }
 
 NPM_SHA1S = {
     "bower": "adf53529c8d4af02ef24fb8d5341c1419d33e2f7",
     "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "vulcanize": "679107f251c19ab7539529b1e3fdd40829e6fc63",
+    "polymer-bundler": "6b296b6099ab5a0e93ca914cbe93e753f2395910",
 }
diff --git a/lib/log/BUILD b/lib/log/BUILD
index 949260d..8e4c927 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -5,21 +5,21 @@
         "//lib/jgit/org.eclipse.jgit:__pkg__",
         "//plugins:__pkg__",
     ],
-    exports = ["@log_api//jar"],
+    exports = ["@log-api//jar"],
 )
 
 java_library(
     name = "ext",
     data = ["//lib:LICENSE-slf4j"],
     visibility = ["//visibility:public"],
-    exports = ["@log_ext//jar"],
+    exports = ["@log-ext//jar"],
 )
 
 java_library(
-    name = "impl_log4j",
+    name = "impl-log4j",
     data = ["//lib:LICENSE-slf4j"],
     visibility = ["//visibility:public"],
-    exports = ["@impl_log4j//jar"],
+    exports = ["@impl-log4j//jar"],
     runtime_deps = [":log4j"],
 )
 
@@ -27,7 +27,7 @@
     name = "jcl-over-slf4j",
     data = ["//lib:LICENSE-slf4j"],
     visibility = ["//visibility:public"],
-    exports = ["@jcl_over_slf4j//jar"],
+    exports = ["@jcl-over-slf4j//jar"],
 )
 
 java_library(
@@ -41,7 +41,7 @@
     name = "jsonevent-layout",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@jsonevent_layout//jar"],
+    exports = ["@jsonevent-layout//jar"],
     runtime_deps = [
         ":json-smart",
         "//lib/commons:lang",
@@ -52,5 +52,5 @@
     name = "json-smart",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@json_smart//jar"],
+    exports = ["@json-smart//jar"],
 )
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index 5c8982a..421caed 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -7,8 +7,8 @@
 merge_maven_jars(
     name = "lucene-core-and-backward-codecs",
     srcs = [
-        "@backward_codecs//jar",
-        "@lucene_core//jar",
+        "@backward-codecs//jar",
+        "@lucene-core//jar",
     ],
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -18,7 +18,7 @@
     name = "lucene-analyzers-common",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@lucene_analyzers_common//jar"],
+    exports = ["@lucene-analyzers-common//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
 
@@ -26,14 +26,14 @@
     name = "lucene-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@lucene_core//jar"],
+    exports = ["@lucene-core//jar"],
 )
 
 java_library(
     name = "lucene-misc",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@lucene_misc//jar"],
+    exports = ["@lucene-misc//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
 
@@ -41,6 +41,6 @@
     name = "lucene-queryparser",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@lucene_queryparser//jar"],
+    exports = ["@lucene-queryparser//jar"],
     runtime_deps = [":lucene-core-and-backward-codecs"],
 )
diff --git a/lib/mime4j/BUILD b/lib/mime4j/BUILD
index e7b85ef..ee407c3 100644
--- a/lib/mime4j/BUILD
+++ b/lib/mime4j/BUILD
@@ -2,12 +2,12 @@
     name = "core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@mime4j_core//jar"],
+    exports = ["@mime4j-core//jar"],
 )
 
 java_library(
     name = "dom",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@mime4j_dom//jar"],
+    exports = ["@mime4j-dom//jar"],
 )
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 66a0960..8595bb5 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -22,5 +22,5 @@
     name = "core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@mina_core//jar"],
+    exports = ["@mina-core//jar"],
 )
diff --git a/lib/openid/BUILD b/lib/openid/BUILD
index 2b36fbb..faa073b 100644
--- a/lib/openid/BUILD
+++ b/lib/openid/BUILD
@@ -2,7 +2,7 @@
     name = "consumer",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@openid_consumer//jar"],
+    exports = ["@openid-consumer//jar"],
     runtime_deps = [
         ":nekohtml",
         ":xerces",
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD
index aebca49..5a82572 100644
--- a/lib/ow2/BUILD
+++ b/lib/ow2/BUILD
@@ -2,21 +2,21 @@
     name = "ow2-asm",
     data = ["//lib:LICENSE-ow2"],
     visibility = ["//visibility:public"],
-    exports = ["@ow2_asm//jar"],
+    exports = ["@ow2-asm//jar"],
 )
 
 java_library(
     name = "ow2-asm-analysis",
     data = ["//lib:LICENSE-ow2"],
     visibility = ["//visibility:public"],
-    exports = ["@ow2_asm_analysis//jar"],
+    exports = ["@ow2-asm-analysis//jar"],
 )
 
 java_library(
     name = "ow2-asm-commons",
     data = ["//lib:LICENSE-ow2"],
     visibility = ["//visibility:public"],
-    exports = ["@ow2_asm_commons//jar"],
+    exports = ["@ow2-asm-commons//jar"],
     runtime_deps = [":ow2-asm-tree"],
 )
 
@@ -24,12 +24,12 @@
     name = "ow2-asm-tree",
     data = ["//lib:LICENSE-ow2"],
     visibility = ["//visibility:public"],
-    exports = ["@ow2_asm_tree//jar"],
+    exports = ["@ow2-asm-tree//jar"],
 )
 
 java_library(
     name = "ow2-asm-util",
     data = ["//lib:LICENSE-ow2"],
     visibility = ["//visibility:public"],
-    exports = ["@ow2_asm_util//jar"],
+    exports = ["@ow2-asm-util//jar"],
 )
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
index 7353b56..57880f4 100644
--- a/lib/powermock/BUILD
+++ b/lib/powermock/BUILD
@@ -5,7 +5,7 @@
     exports = [
         ":powermock-module-junit4-common",
         "//lib:junit",
-        "@powermock_module_junit4//jar",
+        "@powermock-module-junit4//jar",
     ],
 )
 
@@ -16,7 +16,7 @@
     exports = [
         ":powermock-reflect",
         "//lib:junit",
-        "@powermock_module_junit4_common//jar",
+        "@powermock-module-junit4-common//jar",
     ],
 )
 
@@ -27,7 +27,7 @@
     exports = [
         "//lib:junit",
         "//lib/easymock:objenesis",
-        "@powermock_reflect//jar",
+        "@powermock-reflect//jar",
     ],
 )
 
@@ -38,7 +38,7 @@
     exports = [
         ":powermock-api-support",
         "//lib/easymock",
-        "@powermock_api_easymock//jar",
+        "@powermock-api-easymock//jar",
     ],
 )
 
@@ -50,7 +50,7 @@
         ":powermock-core",
         ":powermock-reflect",
         "//lib:junit",
-        "@powermock_api_support//jar",
+        "@powermock-api-support//jar",
     ],
 )
 
@@ -62,6 +62,6 @@
         ":powermock-reflect",
         "//lib:javassist",
         "//lib:junit",
-        "@powermock_core//jar",
+        "@powermock-core//jar",
     ],
 )
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD
index f6b4c5f..8518af7 100644
--- a/lib/prolog/BUILD
+++ b/lib/prolog/BUILD
@@ -2,21 +2,21 @@
     name = "runtime",
     data = ["//lib:LICENSE-prologcafe"],
     visibility = ["//visibility:public"],
-    exports = ["@prolog_runtime//jar"],
+    exports = ["@prolog-runtime//jar"],
 )
 
 java_library(
     name = "runtime-neverlink",
     data = ["//lib:LICENSE-prologcafe"],
     visibility = ["//visibility:public"],
-    exports = ["@prolog_runtime//jar:neverlink"],
+    exports = ["@prolog-runtime//jar:neverlink"],
 )
 
 java_library(
     name = "compiler",
     data = ["//lib:LICENSE-prologcafe"],
     visibility = ["//visibility:public"],
-    exports = ["@prolog_compiler//jar"],
+    exports = ["@prolog-compiler//jar"],
     runtime_deps = [
         ":io",
         ":runtime",
@@ -26,7 +26,7 @@
 java_library(
     name = "io",
     data = ["//lib:LICENSE-prologcafe"],
-    exports = ["@prolog_io//jar"],
+    exports = ["@prolog-io//jar"],
 )
 
 java_library(
@@ -41,14 +41,14 @@
 )
 
 java_binary(
-    name = "compiler_bin",
+    name = "compiler-bin",
     main_class = "BuckPrologCompiler",
     visibility = ["//visibility:public"],
-    runtime_deps = [":compiler_lib"],
+    runtime_deps = [":compiler-lib"],
 )
 
 java_library(
-    name = "compiler_lib",
+    name = "compiler-lib",
     srcs = ["java/BuckPrologCompiler.java"],
     visibility = ["//visibility:public"],
     deps = [
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 43a8bab..4d4dd3a 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -13,22 +13,22 @@
 # limitations under the License.
 
 def prolog_cafe_library(
-    name,
-    srcs,
-    deps = [],
-    **kwargs):
-  native.genrule(
-    name = name + '__pl2j',
-    cmd = '$(location //lib/prolog:compiler_bin) ' +
-      '$$(dirname $@) $@ ' +
-      '$(SRCS)',
-    srcs = srcs,
-    tools = ['//lib/prolog:compiler_bin'],
-    outs = [ name + '.srcjar' ],
-  )
-  native.java_library(
-    name = name,
-    srcs = [':' + name + '__pl2j'],
-    deps = ['//lib/prolog:runtime-neverlink'] + deps,
-    **kwargs
-  )
+        name,
+        srcs,
+        deps = [],
+        **kwargs):
+    native.genrule(
+        name = name + "__pl2j",
+        cmd = "$(location //lib/prolog:compiler-bin) " +
+              "$$(dirname $@) $@ " +
+              "$(SRCS)",
+        srcs = srcs,
+        tools = ["//lib/prolog:compiler-bin"],
+        outs = [name + ".srcjar"],
+    )
+    native.java_library(
+        name = name,
+        srcs = [":" + name + "__pl2j"],
+        deps = ["//lib/prolog:runtime-neverlink"] + deps,
+        **kwargs
+    )
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index e6ec04f..f99365d 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -3,7 +3,7 @@
     testonly = True,
     data = ["//lib:LICENSE-testcontainers"],
     visibility = ["//visibility:public"],
-    exports = ["@duct_tape//jar"],
+    exports = ["@duct-tape//jar"],
 )
 
 java_library(
@@ -11,7 +11,7 @@
     testonly = True,
     data = ["//lib:LICENSE-testcontainers"],
     visibility = ["//visibility:public"],
-    exports = ["@visible_assertions//jar"],
+    exports = ["@visible-assertions//jar"],
 )
 
 java_library(
diff --git a/lib/truth/BUILD b/lib/truth/BUILD
index 82cd98a..db5bc48 100644
--- a/lib/truth/BUILD
+++ b/lib/truth/BUILD
@@ -4,6 +4,7 @@
     visibility = ["//visibility:public"],
     exports = ["@truth//jar"],
     runtime_deps = [
+        ":diffutils",
         "//lib:guava",
         "//lib:junit",
     ],
@@ -33,6 +34,13 @@
 )
 
 java_library(
+    name = "diffutils",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:private"],
+    exports = ["@diffutils//jar"],
+)
+
+java_library(
     name = "truth-proto-extension",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
diff --git a/plugins/BUILD b/plugins/BUILD
index 7252f8c..a7622b2 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -19,6 +19,7 @@
 
 PLUGIN_API = [
     "//java/com/google/gerrit/server",
+    "//java/com/google/gerrit/server/ioutil",
     "//java/com/google/gerrit/server/restapi",
     "//java/com/google/gerrit/pgm/init/api",
     "//java/com/google/gerrit/httpd",
@@ -26,26 +27,29 @@
 ]
 
 EXPORTS = [
+    "//antlr3:query_parser",
     "//java/com/google/gerrit/common:annotations",
     "//java/com/google/gerrit/common:server",
     "//java/com/google/gerrit/extensions:api",
     "//java/com/google/gerrit/index",
     "//java/com/google/gerrit/index:query_exception",
-    "//java/com/google/gerrit/index:query_parser",
     "//java/com/google/gerrit/lifecycle",
     "//java/com/google/gerrit/metrics",
     "//java/com/google/gerrit/metrics/dropwizard",
     "//java/com/google/gerrit/reviewdb:server",
-    "//java/com/google/gwtexpui/server",
+    "//java/com/google/gerrit/server/audit",
+    "//java/com/google/gerrit/server/logging",
+    "//java/com/google/gerrit/server/schema",
+    "//java/com/google/gerrit/util/http",
+    "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
-    "//lib/commons:lang3",
     "//lib/dropwizard:dropwizard-core",
     "//lib/flogger:api",
     "//lib/guice:guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
-    "//lib/guice:javax-inject",
+    "//lib/guice:javax_inject",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
     "//lib/jackson:jackson-core",
@@ -98,19 +102,19 @@
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [
+        "//antlr3:libquery_parser-src.jar",
         "//java/com/google/gerrit/common:libannotations-src.jar",
         "//java/com/google/gerrit/common:libserver-src.jar",
         "//java/com/google/gerrit/extensions:libapi-src.jar",
         "//java/com/google/gerrit/httpd:libhttpd-src.jar",
         "//java/com/google/gerrit/index:libindex-src.jar",
         "//java/com/google/gerrit/index:libquery_exception-src.jar",
-        "//java/com/google/gerrit/index:libquery_parser-src.jar",
         "//java/com/google/gerrit/pgm/init/api:libapi-src.jar",
         "//java/com/google/gerrit/reviewdb:libserver-src.jar",
         "//java/com/google/gerrit/server:libserver-src.jar",
         "//java/com/google/gerrit/server/restapi:librestapi-src.jar",
         "//java/com/google/gerrit/sshd:libsshd-src.jar",
-        "//java/com/google/gwtexpui/server:libserver-src.jar",
+        "//java/com/google/gerrit/util/http:libhttp-src.jar",
     ],
 )
 
@@ -119,14 +123,14 @@
 java_doc(
     name = "plugin-api-javadoc",
     libs = PLUGIN_API + [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gwtexpui/server",
         "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/util/http",
     ],
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 53dccff..22342a6 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 53dccff17c029459999ff70ac886b80626af634b
+Subproject commit 22342a6da26c75b14bc629331c339d1b820b4d39
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 315a115..4f6b685 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 315a11558913fa8f9c6d3b1723e45583b25afa1c
+Subproject commit 4f6b685e12e34a4f583cf84ba1c58ccc2b75e8b0
diff --git a/plugins/external_plugin_deps.bzl b/plugins/external_plugin_deps.bzl
index 391f920..1f7c020 100644
--- a/plugins/external_plugin_deps.bzl
+++ b/plugins/external_plugin_deps.bzl
@@ -1,2 +1,2 @@
 def external_plugin_deps():
-    pass
\ No newline at end of file
+    pass
diff --git a/plugins/hooks b/plugins/hooks
index c45958b..7185e5c 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit c45958b2c63d33643587e063730878b28c97d473
+Subproject commit 7185e5ce46646e952071befd9cf8f4267560b51d
diff --git a/plugins/replication b/plugins/replication
index aaff48d..d557ccc 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit aaff48d82be18d99f50feab6d869cb9c98874f29
+Subproject commit d557ccc642c59a55750f560ce0d98870e1550d65
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index c73171e..54525ff 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit c73171ea9abbeb765d585a92753ce01151355a5c
+Subproject commit 54525ffaed5e8925d97657a622532a00a0006347
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index e4024e9..cc636d7 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit e4024e9d8d8139fc4c658c3af1a5e11e19b2d476
+Subproject commit cc636d7e36afb62455a9f045b125d246fd84afd0
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 32e1953..c119562 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -8,7 +8,7 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-The minimum nodejs version supported is 6.x+
+The minimum nodejs version supported is 8.x+
 
 ```sh
 # Debian experimental
@@ -90,7 +90,7 @@
 
 1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
 2. Set up a local test site. Docs
-   [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
+   [here](https://gerrit-review.googlesource.com/Documentation/linux-quickstart.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
 When your project is set up and works using the classic UI, run a test server
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index 7cb1a11..97151f2 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -1,5 +1,8 @@
 {
   "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 8
+  },
   "env": {
     "browser": true,
     "es6": true
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
index d18e442..c462c6f 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -30,7 +30,7 @@
           'Status',
           'Owner',
           'Assignee',
-          'Project',
+          'Repo',
           'Branch',
           'Updated',
           'Size',
@@ -58,6 +58,22 @@
     isColumnHidden(columnToCheck, columnsToDisplay) {
       return !columnsToDisplay.includes(columnToCheck);
     },
+
+    /**
+     * The Project column was renamed to Repo, but some users may have
+     * preferences that use its old name. If that column is found, rename it
+     * before use.
+     * @param {!Array<string>} columns
+     * @return {!Array<string>} If the column was renamed, returns a new array
+     *     with the corrected name. Otherwise, it returns the original param.
+     */
+    getVisibleColumns(columns) {
+      const projectIndex = columns.indexOf('Project');
+      if (projectIndex === -1) { return columns; }
+      const newColumns = columns.slice(0);
+      newColumns[projectIndex] = 'Repo';
+      return newColumns;
+    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index dc98b59..e8cbb09 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -63,7 +63,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -74,7 +74,7 @@
         'Subject',
         'Status',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Size',
       ];
@@ -83,13 +83,13 @@
     });
 
     test('isColumnHidden', () => {
-      const columnToCheck = 'Project';
+      const columnToCheck = 'Repo';
       let columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -107,5 +107,17 @@
       ];
       assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
     });
+
+    test('getVisibleColumns maps Project to Repo', () => {
+      const columns = [
+        'Subject',
+        'Status',
+        'Owner',
+      ];
+      assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+      assert.deepEqual(
+          element.getVisibleColumns(columns.concat(['Project'])),
+          columns.slice(0).concat(['Repo']));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
index a82253a..f251db8 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../gr-url-encoding-behavior.html">
+<link rel="import" href="../gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <script>
 (function(window) {
   'use strict';
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
similarity index 74%
rename from polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
rename to polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
index 365266c..69703f6 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
@@ -25,6 +25,9 @@
     /**
      * Pretty-encodes a URL. Double-encodes the string, and then replaces
      *   benevolent characters for legibility.
+     * @param {string} url
+     * @param {boolean=} replaceSlashes
+     * @return {string}
      */
     encodeURL(url, replaceSlashes) {
       // @see Issue 4255 regarding double-encoding.
@@ -37,6 +40,18 @@
       }
       return output;
     },
+
+    /**
+     * Single decode for URL components. Will decode plus signs ('+') to spaces.
+     * Note: because this function decodes once, it is not the inverse of
+     * encodeURL.
+     * @param {string} url
+     * @return {string}
+     */
+    singleDecodeURL(url) {
+      const withoutPlus = url.replace(/\+/g, '%20');
+      return decodeURIComponent(withoutPlus);
+    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
new file mode 100644
index 0000000..73dda1b
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<title>gr-url-encoding-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="gr-url-encoding-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-url-encoding-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.URLEncodingBehavior],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('encodeURL', () => {
+      test('double encodes', () => {
+        assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
+        assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
+        assert.equal(element.encodeURL('jkl'), 'jkl');
+        assert.equal(element.encodeURL(''), '');
+      });
+
+      test('does not convert colons', () => {
+        assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
+      });
+
+      test('converts spaces to +', () => {
+        assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+    });
+
+    suite('singleDecodeUrl', () => {
+      test('single decodes', () => {
+        assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
+      });
+
+      test('converts + to space', () => {
+        assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 89f3038..60eaf1f 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -48,7 +48,9 @@
     shouldSuppressKeyboardShortcut(e) {
       e = getKeyboardEvent(e);
       const tagName = Polymer.dom(e).rootTarget.tagName;
-      if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
+      if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
+          (e.keyCode === 13 && tagName === 'A')) {
+        // Suppress shortcuts if the key is 'enter' and target is an anchor.
         return true;
       }
       for (let i = 0; e.path && i < e.path.length; i++) {
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 8e10302..04193dd 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -51,6 +51,7 @@
         behaviors: [Gerrit.KeyboardShortcutBehavior],
         keyBindings: {
           k: '_handleKey',
+          enter: '_handleKey',
         },
         _handleKey() {},
       });
@@ -107,6 +108,17 @@
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
+    test('blocks enter shortcut on an anchor', done => {
+      const anchorEl = document.createElement('a');
+      const element = overlay.querySelector('test-element');
+      element.appendChild(anchorEl);
+      element._handleKey = e => {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+    });
+
     test('modifierPressed returns accurate values', () => {
       const spy = sandbox.spy(element, 'modifierPressed');
       element._handleKey = e => {
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
new file mode 100644
index 0000000..68000bc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
@@ -0,0 +1,75 @@
+<!--
+@license
+Copyright (C) 2018 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';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.SafeTypes */
+  Gerrit.SafeTypes = {};
+
+  const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|\/|#)/i;
+
+  /**
+   * Wraps a string to be used as a URL. An error is thrown if the string cannot
+   * be considered safe.
+   * @constructor
+   * @param {string} url the unwrapped, potentially unsafe URL.
+   */
+  Gerrit.SafeTypes.SafeUrl = function(url) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  };
+
+  /**
+   * Get the string representation of the safe URL.
+   * @returns {string}
+   */
+  Gerrit.SafeTypes.SafeUrl.prototype.asString = function() {
+    return this._url;
+  };
+
+  Gerrit.SafeTypes.safeTypesBridge = function(value, type) {
+    // If the value is being bound to a URL, ensure the value is wrapped in the
+    // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+    // to surface the error.
+    if (type === 'URL') {
+      let safeValue = null;
+      if (value instanceof Gerrit.SafeTypes.SafeUrl) {
+        safeValue = value;
+      } else if (typeof value === 'string') {
+        safeValue = new Gerrit.SafeTypes.SafeUrl(value);
+      }
+      if (safeValue) {
+        return safeValue.asString();
+      }
+    }
+
+    // If the value is being bound to a string or a constant, then the string
+    // can be used as is.
+    if (type === 'STRING' || type === 'CONSTANT') {
+      return value;
+    }
+
+    // Otherwise fail.
+    throw new Error(`Refused to bind value as ${type}: ${value}`);
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
new file mode 100644
index 0000000..bc16b39
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<title>safe-types-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="safe-types-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <safe-types-element></safe-types-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      Polymer({
+        is: 'safe-types-element',
+        behaviors: [Gerrit.SafeTypes],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('SafeUrl accepts valid urls', () => {
+      function accepts(url) {
+        const safeUrl = new element.SafeUrl(url);
+        assert.isOk(safeUrl);
+        assert.equal(url, safeUrl.asString());
+      }
+      accepts('http://www.google.com/');
+      accepts('https://www.google.com/');
+      accepts('HtTpS://www.google.com/');
+      accepts('//www.google.com/');
+      accepts('/c/1234/file/path.html@45');
+      accepts('#hash-url');
+      accepts('mailto:name@example.com');
+    });
+
+    test('SafeUrl rejects invalid urls', () => {
+      function rejects(url) {
+        assert.throws(() => { new element.SafeUrl(url); });
+      }
+      rejects('javascript://alert("evil");');
+      rejects('ftp:example.com');
+      rejects('data:text/html,scary business');
+    });
+
+    suite('safeTypesBridge', () => {
+      function acceptsString(value, type) {
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
+            value);
+      }
+
+      function rejects(value, type) {
+        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
+      }
+
+      test('accepts valid URL strings', () => {
+        acceptsString('/foo/bar', 'URL');
+        acceptsString('#baz', 'URL');
+      });
+
+      test('rejects invalid URL strings', () => {
+        rejects('javascript://void();', 'URL');
+      });
+
+      test('accepts SafeUrl values', () => {
+        const url = '/abc/123';
+        const safeUrl = new element.SafeUrl(url);
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+      });
+
+      test('rejects non-string or non-SafeUrl types', () => {
+        rejects(3.1415926, 'URL');
+      });
+
+      test('accepts any binding to STRING or CONSTANT', () => {
+        acceptsString('foo/bar/baz', 'STRING');
+        acceptsString('lorem ipsum dolor', 'CONSTANT');
+      });
+
+      test('rejects all other types', () => {
+        rejects('foo', 'JAVASCRIPT');
+        rejects('foo', 'HTML');
+        rejects('foo', 'RESOURCE_URL');
+        rejects('foo', 'STYLE');
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index 61df877..45bc5f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -45,7 +45,7 @@
       .header,
       #deletedContainer {
         align-items: center;
-        background: #f6f6f6;
+        background: var(--table-header-background-color);
         border-bottom: 1px dotted var(--border-color);
         display: flex;
         justify-content: space-between;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index 1ad80f5..ea08d89 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -22,7 +22,7 @@
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -64,7 +64,7 @@
       </table>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           class="confirmDialog"
           disabled="[[!_hasNewGroupName]]"
@@ -81,7 +81,7 @@
               params="[[params]]"
               id="createNewModal"></gr-create-group-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index 3c9cdfb..b6a6d27 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -19,7 +19,7 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
index 0a49016..3873083 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-delete-item-dialog">
@@ -27,7 +27,7 @@
         width: 30em;
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Delete [[_computeItemName(itemType)]]"
         confirm-on-enter
         on-confirm="_handleConfirmTap"
@@ -41,7 +41,7 @@
           [[item]]
         </div>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-delete-item-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
index d429d7b..d735acb 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -50,7 +50,7 @@
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
       sandbox.stub(element, '_handleConfirmTap');
-      element.$$('gr-confirm-dialog').fire('confirm');
+      element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
       assert.isTrue(element._handleConfirmTap.called);
     });
@@ -59,7 +59,7 @@
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
       sandbox.stub(element, '_handleCancelTap');
-      element.$$('gr-confirm-dialog').fire('cancel');
+      element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
       assert.isTrue(element._handleCancelTap.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index 85f01e7..ad12a44 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -19,7 +19,7 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
index 0f12e04..d96a935 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
index 74c4891..0557021 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index 70dfe52..6f5cd89 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index 2991a09..8734cd1 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
@@ -110,7 +110,7 @@
                     <td>[[item.email]]</td>
                     <td class="deleteColumn">
                       <gr-button
-                          class="deleteButton"
+                          class="deleteMembersButton"
                           on-tap="_handleDeleteMember">
                         Delete
                       </gr-button>
@@ -125,7 +125,8 @@
             <span class="value">
               <gr-autocomplete
                   id="includedGroupSearchInput"
-                  text="{{_includedGroupSearch}}"
+                  text="{{_includedGroupSearchName}}"
+                  value="{{_includedGroupSearchId}}"
                   query="[[_queryIncludedGroup]]"
                   placeholder="Group Name">
               </gr-autocomplete>
@@ -133,7 +134,7 @@
             <gr-button
                 id="saveIncludedGroups"
                 on-tap="_handleSavingIncludedGroups"
-                disabled="[[!_includedGroupSearch]]">
+                disabled="[[!_includedGroupSearchId]]">
               Add
             </gr-button>
             <table id="includedGroups">
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 8b6ce7a..c698617 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -30,7 +30,8 @@
       groupId: Number,
       _groupMemberSearchId: String,
       _groupMemberSearchName: String,
-      _includedGroupSearch: String,
+      _includedGroupSearchId: String,
+      _includedGroupSearchName: String,
       _loading: {
         type: Boolean,
         value: true,
@@ -145,6 +146,7 @@
             this.$.restAPI.getGroupMembers(this._groupName).then(members => {
               this._groupMembers = members;
             });
+            this._groupMemberSearchName = '';
             this._groupMemberSearchId = '';
           });
     },
@@ -164,9 +166,9 @@
             });
       } else if (this._itemType === 'includedGroup') {
         return this.$.restAPI.deleteIncludedGroup(this._groupName,
-            this._itemName)
+            this._itemId)
             .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
+              if (itemDeleted.status === 204 || itemDeleted.status === 205) {
                 this.$.restAPI.getIncludedGroup(this._groupName)
                     .then(includedGroup => {
                       this._includedGroups = includedGroup;
@@ -197,7 +199,7 @@
 
     _handleSavingIncludedGroups() {
       return this.$.restAPI.saveIncludedGroup(this._groupName,
-          this._includedGroupSearch, err => {
+          this._includedGroupSearchId, err => {
             if (err.status === 404) {
               this.dispatchEvent(new CustomEvent('show-alert', {
                 detail: {message: SAVING_ERROR_TEXT},
@@ -215,16 +217,18 @@
                 .then(includedGroup => {
                   this._includedGroups = includedGroup;
                 });
-            this._includedGroupSearch = '';
+            this._includedGroupSearchName = '';
+            this._includedGroupSearchId = '';
           });
     },
 
     _handleDeleteIncludedGroup(e) {
+      const id = decodeURIComponent(e.model.get('item.id'));
       const name = e.model.get('item.name');
-      if (!name) {
-        return '';
-      }
-      this._itemName = name;
+      const item = name || id;
+      if (!item) { return ''; }
+      this._itemName = item;
+      this._itemId = id;
       this._itemType = 'includedGroup';
       this.$.overlay.open();
     },
@@ -261,7 +265,7 @@
               if (!response.hasOwnProperty(key)) { continue; }
               groups.push({
                 name: key,
-                value: response[key],
+                value: decodeURIComponent(response[key].id),
               });
             }
             return groups;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index 29a3cca..81123e7 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -117,6 +117,20 @@
             return Promise.resolve({});
           }
         },
+        getSuggestedGroups(input) {
+          if (input.startsWith('test')) {
+            return Promise.resolve({
+              'test-admin': {
+                id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+              },
+              'test/Administrator (admin)': {
+                id: 'test%3Aadmin',
+              },
+            });
+          } else {
+            return Promise.resolve({});
+          }
+        },
         getLoggedIn() { return Promise.resolve(true); },
         getConfig() {
           return Promise.resolve();
@@ -159,7 +173,7 @@
           'https://test/site/group/url');
     });
 
-    test('save correctly', () => {
+    test('save members correctly', () => {
       element._groupOwner = true;
 
       const memberName = 'test-admin';
@@ -169,7 +183,7 @@
             return Promise.resolve({});
           });
 
-      const button = Polymer.dom(element.root).querySelector('gr-button');
+      const button = element.$.saveGroupMember;
 
       assert.isTrue(button.hasAttribute('disabled'));
 
@@ -186,6 +200,33 @@
       });
     });
 
+    test('save included groups correctly', () => {
+      element._groupOwner = true;
+
+      const includedGroupName = 'testName';
+
+      const saveIncludedGroupStub = sandbox.stub(
+          element.$.restAPI, 'saveIncludedGroup', () => {
+            return Promise.resolve({});
+          });
+
+      const button = element.$.saveIncludedGroups;
+
+      assert.isTrue(button.hasAttribute('disabled'));
+
+      element.$.includedGroupSearchInput.text = includedGroupName;
+      element.$.includedGroupSearchInput.value = 'testId';
+
+      assert.isFalse(button.hasAttribute('disabled'));
+
+      return element._handleSavingIncludedGroups().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+        assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+      });
+    });
+
     test('add included group 404 shows helpful error text', () => {
       element._groupOwner = true;
 
@@ -220,6 +261,20 @@
       });
     });
 
+    test('_getGroupSuggestions empty', () => {
+      return element._getGroupSuggestions('nonexistent').then(groups => {
+        assert.equal(groups.length, 0);
+      });
+    });
+
+    test('_getGroupSuggestions non-empty', () => {
+      return element._getGroupSuggestions('test').then(groups => {
+        assert.equal(groups.length, 2);
+        assert.equal(groups[0].name, 'test-admin');
+        assert.equal(groups[1].name, 'test/Administrator (admin)');
+      });
+    });
+
     test('_computeHideItemClass returns string for admin', () => {
       const admin = true;
       const owner = false;
@@ -240,7 +295,7 @@
 
     test('delete member', () => {
       const deletelBtns = Polymer.dom(element.root)
-          .querySelectorAll('.deleteButton');
+          .querySelectorAll('.deleteMembersButton');
       MockInteractions.tap(deletelBtns[0]);
       assert.equal(element._itemId, '1000097');
       assert.equal(element._itemName, 'jane');
@@ -255,6 +310,20 @@
       assert.equal(element._itemName, '1000098');
     });
 
+    test('delete included groups', () => {
+      const deletelBtns = Polymer.dom(element.root)
+          .querySelectorAll('.deleteIncludedGroupButton');
+      MockInteractions.tap(deletelBtns[0]);
+      assert.equal(element._itemId, 'testId');
+      assert.equal(element._itemName, 'testName');
+      MockInteractions.tap(deletelBtns[1]);
+      assert.equal(element._itemId, 'testId2');
+      assert.equal(element._itemName, 'testName2');
+      MockInteractions.tap(deletelBtns[2]);
+      assert.equal(element._itemId, 'testId3');
+      assert.equal(element._itemName, 'testName3');
+    });
+
     test('_computeLoadingClass', () => {
       assert.equal(element._computeLoadingClass(true), 'loading');
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
index 1e19107..349cadc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -23,7 +23,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.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">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
index f718806..b6e56de 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -19,7 +19,7 @@
 
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 24d0c62..799b831 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -196,11 +196,10 @@
     },
 
     _handleUpdateInheritFrom(e) {
-      const projectId = decodeURIComponent(e.detail.value);
       if (!this._inheritsFrom) {
         this._inheritsFrom = {};
       }
-      this._inheritsFrom.id = projectId;
+      this._inheritsFrom.id = e.detail.value;
       this._inheritsFrom.name = this._inheritFromFilter;
       this._handleAccessModified();
     },
@@ -214,7 +213,7 @@
             for (const key in response) {
               if (!response.hasOwnProperty(key)) { continue; }
               projects.push({
-                name: key,
+                name: response[key].name,
                 value: response[key].id,
               });
             }
@@ -361,18 +360,24 @@
         remove: {},
       };
 
+      const originalInheritsFromId = this._originalInheritsFrom ?
+          this.singleDecodeURL(this._originalInheritsFrom.id) :
+          null;
+      const inheritsFromId = this._inheritsFrom ?
+          this.singleDecodeURL(this._inheritsFrom.id) :
+          null;
+
       const inheritFromChanged =
           // Inherit from changed
-          (this._originalInheritsFrom &&
-          this._originalInheritsFrom.id !== this._inheritsFrom.id) ||
-          // Inherit froma dded (did not have one initially);
-          (!this._originalInheritsFrom && this._inheritsFrom
-              && this._inheritsFrom.id);
+          (originalInheritsFromId
+              && originalInheritsFromId !== inheritsFromId) ||
+          // Inherit from added (did not have one initially);
+          (!originalInheritsFromId && inheritsFromId);
 
       this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
 
       if (inheritFromChanged) {
-        addRemoveObj.parent = this._inheritsFrom.id;
+        addRemoveObj.parent = inheritsFromId;
       }
       return addRemoveObj;
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index e5b0a25..20e2b8e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -238,6 +238,14 @@
           'none');
     });
 
+    test('_handleUpdateInheritFrom', () => {
+      element._inheritFromFilter = 'foo bar baz';
+      element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+      assert.isOk(element._inheritsFrom);
+      assert.equal(element._inheritsFrom.id, 'abc+123');
+      assert.equal(element._inheritsFrom.name, 'foo bar baz');
+    });
+
     test('_computeLoadingClass', () => {
       assert.equal(element._computeLoadingClass(true), 'loading');
       assert.equal(element._computeLoadingClass(false), '');
@@ -422,6 +430,14 @@
         });
       });
 
+      test('_handleSaveForReview new parent with spaces', () => {
+        element._inheritsFrom = {id: 'spaces+in+project+name'};
+        element._originalInheritsFrom = {id: 'old-project'};
+        assert.deepEqual(element._computeAddAndRemove(), {
+          parent: 'spaces in project name', add: {}, remove: {},
+        });
+      });
+
       test('_handleSaveForReview rules', () => {
         // Delete a rule.
         element._local['refs/*'].permissions.owner.rules[123].deleted = true;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
index f42652d..7db4e4c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
@@ -27,7 +27,12 @@
       }
     </style>
     <h3>[[title]]</h3>
-    <gr-button on-tap="_onCommandTap">[[title]]</gr-button>
+    <gr-button
+        title$="[[tooltip]]"
+        disabled$="[[disabled]]"
+        on-tap="_onCommandTap">
+      [[title]]
+    </gr-button>
   </template>
   <script src="gr-repo-command.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index e49c4de..bcdb7f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -22,6 +22,8 @@
 
     properties: {
       title: String,
+      disabled: Boolean,
+      tooltip: String,
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
index 510f654..dba01aa 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
@@ -23,7 +23,7 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
@@ -50,7 +50,8 @@
               on-command-tap="_handleEditRepoConfig">
           </gr-repo-command>
           <gr-repo-command
-              title="Run GC"
+              title="[[_repoConfig.actions.gc.label]]"
+              tooltip="[[_repoConfig.actions.gc.title]]"
               hidden$="[[!_repoConfig.actions.gc.enabled]]"
               on-command-tap="_handleRunningGC">
           </gr-repo-command>
@@ -64,7 +65,7 @@
       </div>
     </main>
     <gr-overlay id="createChangeOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createChangeDialog"
           confirm-label="Create"
           disabled="[[!_canCreate]]"
@@ -79,7 +80,7 @@
               can-create="{{_canCreate}}"
               repo-name="[[repo]]"></gr-create-change-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
index e9bc674..fccfa6a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
@@ -25,6 +25,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-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -189,7 +190,7 @@
       </gr-overlay>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           disabled="[[!_hasNewItemName]]"
           confirm-label="Create"
@@ -206,7 +207,7 @@
               item-detail="[[detailType]]"
               repo-name="[[_repo]]"></gr-create-pointer-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
index 6c1f8fb..7e1c385 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -20,7 +20,7 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -74,7 +74,7 @@
       </table>
     </gr-list-view>
     <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="createDialog"
           class="confirmDialog"
           disabled="[[!_hasNewRepoName]]"
@@ -90,7 +90,7 @@
               params="[[params]]"
               id="createNewModal"></gr-create-repo-dialog>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index 5560972..5a8b5b1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -120,12 +120,7 @@
           .then(repos => {
             // Late response.
             if (filter !== this._filter || !repos) { return; }
-            this._repos = Object.keys(repos)
-             .map(key => {
-               const repo = repos[key];
-               repo.name = key;
-               return repo;
-             });
+            this._repos = repos;
             this._loading = false;
           });
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index 704974d..fd6eb80 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -243,6 +243,22 @@
                 </span>
               </section>
               <section>
+                <span class="title">
+                  Set new changes to "work in progress" by default</span>
+                <span class="value">
+                  <gr-select
+                      id="setAllNewChangesWorkInProgressByDefaultSelect"
+                      bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
                 <span class="title">Maximum Git object size limit</span>
                 <span class="value">
                   <input
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
index febd446..d59deed4 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -19,7 +19,7 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -111,13 +111,13 @@
         </a>
         <gr-select
             id="force"
-            class$="[[_computeForceClass(permission)]]"
+            class$="[[_computeForceClass(permission, rule.value.action)]]"
             bind-value="{{rule.value.force}}"
             on-change="_handleValueChange">
           <select disabled$="[[!editing]]">
             <template
                 is="dom-repeat"
-                items="[[_computeForceOptions(permission)]]">
+                items="[[_computeForceOptions(permission, rule.value.action)]]">
               <option value="[[item.value]]">[[item.name]]</option>
             </template>
           </select>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 4af4952..b99125c 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -33,22 +33,24 @@
     'INTERACTIVE',
   ];
 
-  const DROPDOWN_OPTIONS = [
-    'ALLOW',
-    'DENY',
-    'BLOCK',
-  ];
+  const Action = {
+    ALLOW: 'ALLOW',
+    DENY: 'DENY',
+    BLOCK: 'BLOCK',
+  };
 
-  const FORCE_PUSH_OPTIONS = [
-    {
-      name: 'Block all pushes, block force push only',
-      value: false,
-    },
-    {
-      name: 'Allow fast-forward only push, allow all pushes',
-      value: true,
-    },
-  ];
+  const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+
+  const ForcePushOptions = {
+    ALLOW: [
+      {name: 'Allow pushing (but not force pushing)', value: false},
+      {name: 'Allow pushing with or without force', value: true},
+    ],
+    BLOCK: [
+      {name: 'Block pushing with or without force', value: false},
+      {name: 'Block force pushing', value: true},
+    ],
+  };
 
   const FORCE_EDIT_OPTIONS = [
     {
@@ -117,13 +119,17 @@
       this._setOriginalRuleValues(rule.value);
     },
 
-    _computeForce(permission) {
-      return this.permissionValues.push.id === permission ||
-          this.permissionValues.editTopicName.id === permission;
+    _computeForce(permission, action) {
+      if (this.permissionValues.push.id === permission &&
+          action !== Action.DENY) {
+        return true;
+      }
+
+      return this.permissionValues.editTopicName.id === permission;
     },
 
-    _computeForceClass(permission) {
-      return this._computeForce(permission) ? 'force' : '';
+    _computeForceClass(permission, action) {
+      return this._computeForce(permission, action) ? 'force' : '';
     },
 
     _computeGroupPath(group) {
@@ -156,9 +162,15 @@
       return classList.join(' ');
     },
 
-    _computeForceOptions(permission) {
+    _computeForceOptions(permission, action) {
       if (permission === this.permissionValues.push.id) {
-        return FORCE_PUSH_OPTIONS;
+        if (action === Action.ALLOW) {
+          return ForcePushOptions.ALLOW;
+        } else if (action === Action.BLOCK) {
+          return ForcePushOptions.BLOCK;
+        } else {
+          return [];
+        }
       } else if (permission === this.permissionValues.editTopicName.id) {
         return FORCE_EDIT_OPTIONS;
       }
@@ -166,6 +178,7 @@
     },
 
     _getDefaultRuleValues(permission, label) {
+      const ruleAction = Action.ALLOW;
       const value = {};
       if (permission === 'priority') {
         value.action = PRIORITY_OPTIONS[0];
@@ -173,16 +186,17 @@
       } else if (label) {
         value.min = label.values[0].value;
         value.max = label.values[label.values.length - 1].value;
-      } else if (this._computeForce(permission)) {
-        value.force = this._computeForceOptions(permission)[0].value;
+      } else if (this._computeForce(permission, ruleAction)) {
+        value.force =
+            this._computeForceOptions(permission, ruleAction)[0].value;
       }
       value.action = DROPDOWN_OPTIONS[0];
       return value;
     },
 
     _setDefaultRuleValues() {
-      this.set('rule.value',
-          this._getDefaultRuleValues(this.permission, this.label));
+      this.set('rule.value', this._getDefaultRuleValues(this.permission,
+          this.label));
     },
 
     _computeOptions(permission) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 5b6f947..f85c2b2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -50,16 +50,16 @@
     suite('unit tests', () => {
       test('_computeForce, _computeForceClass, and _computeForceOptions',
           () => {
-            const FORCE_PUSH_OPTIONS = [
-              {
-                name: 'Block all pushes, block force push only',
-                value: false,
-              },
-              {
-                name: 'Allow fast-forward only push, allow all pushes',
-                value: true,
-              },
-            ];
+            const ForcePushOptions = {
+              ALLOW: [
+                {name: 'Allow pushing (but not force pushing)', value: false},
+                {name: 'Allow pushing with or without force', value: true},
+              ],
+              BLOCK: [
+                {name: 'Block pushing with or without force', value: false},
+                {name: 'Block force pushing', value: true},
+              ],
+            };
 
             const FORCE_EDIT_OPTIONS = [
               {
@@ -72,10 +72,26 @@
               },
             ];
             let permission = 'push';
-            assert.isTrue(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), 'force');
-            assert.deepEqual(element._computeForceOptions(permission),
-                FORCE_PUSH_OPTIONS);
+            let action = 'ALLOW';
+            assert.isTrue(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action),
+                'force');
+            assert.deepEqual(element._computeForceOptions(permission, action),
+                ForcePushOptions.ALLOW);
+
+            action = 'BLOCK';
+            assert.isTrue(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action),
+                'force');
+            assert.deepEqual(element._computeForceOptions(permission, action),
+                ForcePushOptions.BLOCK);
+
+            action = 'DENY';
+            assert.isFalse(element._computeForce(permission, action));
+            assert.equal(element._computeForceClass(permission, action), '');
+            assert.equal(
+                element._computeForceOptions(permission, action).length, 0);
+
             permission = 'editTopicName';
             assert.isTrue(element._computeForce(permission));
             assert.equal(element._computeForceClass(permission), 'force');
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 6ea7cf3..6fa799e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -17,7 +17,7 @@
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
@@ -98,10 +98,10 @@
         font-family: var(--monospace-font-family);
       }
       .u-green {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
       }
       .u-red {
-        color: #D32F2F;
+        color: var(--vote-text-color-disliked);
       }
       .label.u-green:not(.u-monospace),
       .label.u-red:not(.u-monospace) {
@@ -169,18 +169,21 @@
         <span class="placeholder">--</span>
       </template>
     </td>
-    <td class="cell project"
-        hidden$="[[isColumnHidden('Project', visibleChangeTableColumns)]]">
-      <a class="fullProject" href$="[[_computeProjectURL(change.project)]]">
-        [[change.project]]
+    <td class="cell repo"
+        hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]">
+      <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
+        [[_computeRepoDisplay(change)]]
       </a>
-      <a class="truncatedProject" href$="[[_computeProjectURL(change.project)]]">
-        [[_computeTruncatedProject(change.project)]]
+      <a
+          class="truncatedRepo"
+          href$="[[_computeRepoUrl(change)]]"
+          title$="[[_computeRepoDisplay(change)]]">
+        [[_computeRepoDisplay(change, 'true')]]
       </a>
     </td>
     <td class="cell branch"
         hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
-      <a href$="[[_computeProjectBranchURL(change)]]">
+      <a href$="[[_computeRepoBranchURL(change)]]">
         [[change.branch]]
       </a>
       <template is="dom-if" if="[[change.topic]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 259580b..65fe9ea 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -39,6 +39,11 @@
         type: String,
         computed: '_computeChangeURL(change)',
       },
+      needsReview: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: '_computeItemNeedsReview(change.reviewed)',
+      },
       statuses: {
         type: Array,
         computed: 'changeStatuses(change)',
@@ -62,6 +67,10 @@
       Gerrit.URLEncodingBehavior,
     ],
 
+    _computeItemNeedsReview(reviewed) {
+      return !reviewed;
+    },
+
     _computeChangeURL(change) {
       return Gerrit.Nav.getUrlForChange(change);
     },
@@ -122,22 +131,36 @@
       return '';
     },
 
-    _computeProjectURL(project) {
-      return Gerrit.Nav.getUrlForProjectChanges(project, true);
+    _computeRepoUrl(change) {
+      return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
+          change.internalHost);
     },
 
-    _computeProjectBranchURL(change) {
-      return Gerrit.Nav.getUrlForBranch(change.branch, change.project);
+    _computeRepoBranchURL(change) {
+      return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
+          change.internalHost);
     },
 
     _computeTopicURL(change) {
       if (!change.topic) { return ''; }
-      return Gerrit.Nav.getUrlForTopic(change.topic);
+      return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
     },
 
-    _computeTruncatedProject(project) {
-      if (!project) { return ''; }
-      return this.truncatePath(project, 2);
+    /**
+     * Computes the display string for the project column. If there is a host
+     * specified in the change detail, the string will be prefixed with it.
+     *
+     * @param {!Object} change
+     * @param {string=} truncate whether or not the project name should be
+     *     truncated. If this value is truthy, the name will be truncated.
+     * @return {string}
+     */
+    _computeRepoDisplay(change, truncate) {
+      if (!change || !change.project) { return ''; }
+      let str = '';
+      if (change.internalHost) { str += change.internalHost + '/'; }
+      str += truncate ? this.truncatePath(change.project, 2) : change.project;
+      return str;
     },
 
     _computeAccountStatusString(account) {
@@ -174,5 +197,14 @@
         return 'XL';
       }
     },
+
+    toggleReviewed() {
+      const newVal = !this.change.reviewed;
+      this.set('change.reviewed', newVal);
+      this.dispatchEvent(new CustomEvent('toggle-reviewed', {
+        bubbles: true,
+        detail: {change: this.change, reviewed: newVal},
+      }));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index 81e1034..aaad362 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -37,8 +37,10 @@
 <script>
   suite('gr-change-list-item tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
@@ -46,6 +48,8 @@
       element = fixture('basic');
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('computed fields', () => {
       assert.equal(element._computeLabelClass({labels: {}}),
           'cell label u-gray-background');
@@ -121,7 +125,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
         'Size',
@@ -131,31 +135,13 @@
 
       for (const column of element.columnNames) {
         const elementClass = '.' + column.toLowerCase();
+        assert.isOk(element.$$(elementClass),
+            `Expect ${elementClass} element to be found`);
         assert.isFalse(element.$$(elementClass).hidden);
       }
     });
 
-    test('no hidden columns', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Project',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      flushAsynchronousOperations();
-
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        assert.isFalse(element.$$(elementClass).hidden);
-      }
-    });
-
-    test('project column hidden', () => {
+    test('repo column hidden', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
@@ -170,7 +156,7 @@
 
       for (const column of element.columnNames) {
         const elementClass = '.' + column.toLowerCase();
-        if (column === 'Project') {
+        if (column === 'Repo') {
           assert.isTrue(element.$$(elementClass).hidden);
         } else {
           assert.isFalse(element.$$(elementClass).hidden);
@@ -249,5 +235,39 @@
         deletions: 999,
       }), 'XL');
     });
+
+    test('change params passed to gr-navigation', () => {
+      sandbox.stub(Gerrit.Nav);
+      const change = {
+        internalHost: 'test-host',
+        project: 'test-repo',
+        topic: 'test-topic',
+        branch: 'test-branch',
+      };
+      element.change = change;
+      flushAsynchronousOperations();
+
+      assert.deepEqual(Gerrit.Nav.getUrlForChange.lastCall.args, [change]);
+      assert.deepEqual(Gerrit.Nav.getUrlForProjectChanges.lastCall.args,
+          [change.project, true, change.internalHost]);
+      assert.deepEqual(Gerrit.Nav.getUrlForBranch.lastCall.args,
+          [change.branch, change.project, null, change.internalHost]);
+      assert.deepEqual(Gerrit.Nav.getUrlForTopic.lastCall.args,
+          [change.topic, change.internalHost]);
+    });
+
+    test('_computeRepoDisplay', () => {
+      const change = {
+        project: 'a/test/repo',
+        internalHost: 'host',
+      };
+      assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
+      assert.equal(element._computeRepoDisplay(change, true),
+          'host/…/test/repo');
+      delete change.internalHost;
+      assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+      assert.equal(element._computeRepoDisplay(change, true),
+          '…/test/repo');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 3a7ff10..48d5075 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -16,7 +16,7 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
@@ -86,7 +86,9 @@
           changes="{{_changes}}"
           preferences="[[preferences]]"
           selected-index="{{viewState.selectedChangeIndex}}"
-          show-star="[[_loggedIn]]"></gr-change-list>
+          show-star="[[_loggedIn]]"
+          on-toggle-star="_handleToggleStar"
+          on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
       <nav class$="[[_computeNavClass(_loading)]]">
           Page [[_computePage(_offset, _changesPerPage)]]
           <a id="prevArrow"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 2a05a2f..d5ae7c1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -263,5 +263,15 @@
     _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
     },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
+
+    _handleToggleReviewed(e) {
+      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+          e.detail.reviewed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 3509f35..4ebe027 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
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 dc41f59..eb916c1 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
@@ -18,8 +18,9 @@
   'use strict';
 
   const NUMBER_FIXED_COLUMNS = 3;
-
   const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+  const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+  const MAX_SHORTCUT_CHARS = 5;
 
   Polymer({
     is: 'gr-change-list',
@@ -111,7 +112,8 @@
       'n ]': '_handleNKey',
       'o': '_handleOKey',
       'p [': '_handlePKey',
-      'shift+r': '_handleRKey',
+      'r': '_handleRKey',
+      'shift+r': '_handleShiftRKey',
       's': '_handleSKey',
     },
 
@@ -149,7 +151,7 @@
         this.showNumber = !!(preferences &&
             preferences.legacycid_in_change_table);
         this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
-            preferences.change_table : this.columnNames;
+            this.getVisibleColumns(preferences.change_table) : this.columnNames;
       } else {
         // Not logged in.
         this.showNumber = false;
@@ -180,9 +182,15 @@
     },
 
     _computeLabelShortcut(labelName) {
-      return labelName.split('-').reduce((a, i) => {
-        return a + i[0].toUpperCase();
-      }, '');
+      if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+        labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+      }
+      return labelName.split('-')
+          .reduce((a, i) => {
+            if (!i) { return a; }
+            return a + i[0].toUpperCase();
+          }, '')
+          .slice(0, MAX_SHORTCUT_CHARS);
     },
 
     _changesChanged(changes) {
@@ -275,6 +283,24 @@
     },
 
     _handleRKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._toggleReviewedForIndex(this.selectedIndex);
+    },
+
+    _toggleReviewedForIndex(index) {
+      const changeEls = this._getListItems();
+      if (index >= changeEls.length || !changeEls[index]) {
+        return;
+      }
+
+      const changeEl = changeEls[index];
+      changeEl.toggleReviewed();
+    },
+
+    _handleShiftRKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -300,10 +326,7 @@
       }
 
       const changeEl = changeEls[index];
-      const change = changeEl.change;
-      const newVal = !change.starred;
-      changeEl.set('change.starred', newVal);
-      this.$.restAPI.saveChangeStarred(change._number, newVal);
+      changeEl.$$('gr-change-star').toggleStar();
     },
 
     _changeForIndex(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 3dd8c9d..3cf5f9a 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
@@ -127,7 +127,13 @@
       assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
       assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
       assert.equal(element._computeLabelShortcut(
+          'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+      assert.equal(element._computeLabelShortcut(
           'Some-Special-Label-7'), 'SSL7');
+      assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+          'TMD');
+      assert.equal(element._computeLabelShortcut(
+          'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
     });
 
     test('colspans', () => {
@@ -304,7 +310,7 @@
             'Status',
             'Owner',
             'Assignee',
-            'Project',
+            'Repo',
             'Branch',
             'Updated',
             'Size',
@@ -343,10 +349,10 @@
         flushAsynchronousOperations();
       });
 
-      test('all columns except project visible', () => {
+      test('all columns except repo visible', () => {
         for (const column of element.changeTableColumns) {
           const elementClass = '.' + column.toLowerCase();
-          if (column === 'Project') {
+          if (column === 'Repo') {
             assert.isTrue(element.$$(elementClass).hidden);
           } else {
             assert.isFalse(element.$$(elementClass).hidden);
@@ -445,6 +451,14 @@
       MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
       assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
           'Should navigate to /c/4/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+      const change = element._changeForIndex(element.selectedIndex);
+      assert.equal(change.reviewed, true,
+          'Should mark change as reviewed');
+      MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+      assert.equal(change.reviewed, false,
+          'Should mark change as unreviewed');
     });
 
     test('highlight attribute is updated correctly', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index 1935962..b18e8cd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -60,7 +60,9 @@
           account="[[account]]"
           preferences="[[preferences]]"
           selected-index="{{viewState.selectedChangeIndex}}"
-          sections="[[_results]]"></gr-change-list>
+          sections="[[_results]]"
+          on-toggle-star="_handleToggleStar"
+          on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index f86c98c..d90c6ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -25,11 +25,21 @@
   // gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
   const DEFAULT_SECTIONS = [
     {
+      // Changes with unpublished draft comments. This section is omitted when
+      // viewing other users, so we don't need to filter anything out.
+      name: 'Has draft comments',
+      query: 'has:draft',
+      selfOnly: true,
+      hideIfEmpty: true,
+      suffixForDashboard: 'limit:10',
+    },
+    {
       // WIP open changes owned by viewing user. This section is omitted when
       // viewing other users, so we don't need to filter anything out.
       name: 'Work in progress',
       query: 'is:open owner:${user} is:wip',
       selfOnly: true,
+      hideIfEmpty: true,
     },
     {
       // Non-WIP open changes owned by viewed user. Filter out changes ignored
@@ -58,7 +68,8 @@
       // changes not owned by the viewing user (the one instance of
       // 'owner:self' is intentional and implements this logic).
       query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
-          '(owner:${user} OR reviewer:${user} OR assignee:${user})',
+          '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+          'OR cc:${user})',
       suffixForDashboard: '-age:4w limit:10',
     },
   ];
@@ -160,16 +171,10 @@
     _getUserDashboard(user, sections, title) {
       sections = sections
         .filter(section => (user === 'self' || !section.selfOnly))
-        .map(section => {
-          const dashboardSection = {
-            name: section.name,
-            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-          };
-          if (section.suffixForDashboard) {
-            dashboardSection.suffixForDashboard = section.suffixForDashboard;
-          }
-          return dashboardSection;
-        });
+        .map(section => Object.assign({}, section, {
+          name: section.name,
+          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+        }));
       return Promise.resolve({title, sections});
     },
 
@@ -197,49 +202,71 @@
       // in an async so that attachment to the DOM can take place first.
       const title = params.title || this._computeTitle(user);
       this.async(() => this.fire('title-change', {title}));
+      return this._reload();
+    },
 
+    /**
+     * Reloads the element.
+     *
+     * @return {Promise<!Object>}
+     */
+    _reload() {
       this._loading = true;
-
-      const dashboardPromise = params.project ?
-          this._getProjectDashboard(params.project, params.dashboard) :
+      const {project, dashboard, title, user, sections} = this.params;
+      const dashboardPromise = project ?
+          this._getProjectDashboard(project, dashboard) :
           this._getUserDashboard(
-              params.user || 'self',
-              params.sections || DEFAULT_SECTIONS,
-              params.title || this._computeTitle(params.user));
+              user || 'self',
+              sections || DEFAULT_SECTIONS,
+              title || this._computeTitle(user));
 
-      return dashboardPromise.then(dashboard => {
-        if (!dashboard) {
-          this._loading = false;
-          return;
+      return dashboardPromise.then(this._fetchDashboardChanges.bind(this))
+          .then(() => {
+            this.$.reporting.dashboardDisplayed();
+          }).catch(err => {
+            console.warn(err);
+          }).then(() => { this._loading = false; });
+    },
+
+    /**
+     * Fetches the changes for each dashboard section and sets this._results
+     * with the response.
+     *
+     * @param {!Object} res
+     * @return {Promise}
+     */
+    _fetchDashboardChanges(res) {
+      if (!res) { return Promise.resolve(); }
+      const queries = res.sections.map(section => {
+        if (section.suffixForDashboard) {
+          return section.query + ' ' + section.suffixForDashboard;
         }
-        const queries = dashboard.sections.map(section => {
-          if (section.suffixForDashboard) {
-            return section.query + ' ' + section.suffixForDashboard;
-          }
-          return section.query;
-        });
-        const req =
-            this.$.restAPI.getChanges(null, queries, null, this.options);
-        return req.then(response => {
-          this._loading = false;
-          this._results = response.map((results, i) => {
-            return {
-              sectionName: dashboard.sections[i].name,
-              query: dashboard.sections[i].query,
-              results,
-            };
-          });
-        });
-      }).then(() => {
-        this.$.reporting.dashboardDisplayed();
-      }).catch(err => {
-        this._loading = false;
-        console.warn(err);
+        return section.query;
       });
+
+      return this.$.restAPI.getChanges(null, queries, null, this.options)
+          .then(changes => {
+            this._results = changes.map((results, i) => ({
+              sectionName: res.sections[i].name,
+              query: res.sections[i].query,
+              results,
+            })).filter((section, i) => !res.sections[i].hideIfEmpty ||
+                section.results.length);
+          });
     },
 
     _computeUserHeaderClass(userParam) {
       return userParam === 'self' ? 'hide' : '';
     },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
+
+    _handleToggleReviewed(e) {
+      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+          e.detail.reviewed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index a1da018..cac2627 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -207,8 +207,11 @@
                     sections: [
                       {name: 'section 1', query: 'query 1'},
                       {name: 'section 2', query: 'query 2 for self'},
-                      {name: 'section 3', query: 'self only query'},
                       {
+                        name: 'section 3',
+                        query: 'self only query',
+                        selfOnly: true,
+                      }, {
                         name: 'section 4',
                         query: 'query 4',
                         suffixForDashboard: 'suffix',
@@ -239,6 +242,21 @@
       });
     });
 
+    test('hideIfEmpty sections', () => {
+      const sections = [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ];
+      getChangesStub.restore();
+      sandbox.stub(element.$.restAPI, 'getChanges')
+          .returns(Promise.resolve([[], ['nonempty']]));
+
+      return element._fetchDashboardChanges({sections}).then(() => {
+        assert.equal(element._results.length, 1);
+        assert.equal(element._results[0].sectionName, 'test2');
+      });
+    });
+
     test('_computeUserHeaderClass', () => {
       assert.equal(element._computeUserHeaderClass(undefined), '');
       assert.equal(element._computeUserHeaderClass(''), '');
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
index c94a716..582c83b 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -37,7 +37,6 @@
         threshold="[[suggestFrom]]"
         query="[[query]]"
         allow-non-suggested-values="[[allowAnyInput]]"
-        no-debounce
         on-commit="_handleInputCommit"
         clear-on-commit
         warn-uncommitted
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 5cb3b77..86e3903 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -69,6 +69,8 @@
         type: String,
         observer: '_inputTextChanged',
       },
+
+      _loggedIn: Boolean,
     },
 
     behaviors: [
@@ -79,6 +81,9 @@
       this.$.restAPI.getConfig().then(cfg => {
         this._config = cfg;
       });
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
     },
 
     get focusStart() {
@@ -144,7 +149,9 @@
     },
 
     _getReviewerSuggestions(input) {
-      if (!this.change || !this.change._number) { return Promise.resolve([]); }
+      if (!this.change || !this.change._number || !this._loggedIn) {
+        return Promise.resolve([]);
+      }
 
       const api = this.$.restAPI;
       const xhr = this.allowAnyUser ?
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 7d5ddd8..03a0be8 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -65,7 +65,7 @@
     let suggestion3;
     let element;
 
-    setup(() => {
+    setup(done => {
       owner = makeAccount();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
@@ -78,6 +78,10 @@
         },
       };
 
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+
       element = fixture('basic');
       element.change = {
         _number: 42,
@@ -88,6 +92,7 @@
         },
       };
       sandbox = sinon.sandbox.create();
+      return flush(done);
     });
 
     teardown(() => {
@@ -168,6 +173,19 @@
           }).then(done);
         });
       });
+
+      test('_getReviewerSuggestions short circuits when logged out', () => {
+        // API call is already stubbed.
+        const xhrSpy = element.$.restAPI.getChangeSuggestedReviewers;
+        element._loggedIn = false;
+        return element._getReviewerSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          element._loggedIn = true;
+          return element._getReviewerSuggestions('').then(() => {
+            assert.isTrue(xhrSpy.called);
+          });
+        });
+      });
     });
 
     test('allowAnyUser', 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 431d56f4..6b6e90b 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
@@ -24,6 +24,7 @@
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -34,6 +35,7 @@
 <link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
 <link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
 <link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+<link rel="import" href="../gr-confirm-submit-dialog/gr-confirm-submit-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-actions">
@@ -52,7 +54,7 @@
       gr-button,
       gr-dropdown {
         /* px because don't have the same font size */
-        margin-left: 12px;
+        margin-left: 8px;
       }
       #actionLoadingMessage {
         align-items: center;
@@ -68,32 +70,41 @@
         margin-right: .2rem;
         width: 1.2rem;
       }
+      gr-button {
+        min-height: 2.25em;
+      }
+      gr-dropdown {
+        --gr-button: {
+          min-height: 2.25em;
+        }
+      }
       #moreActions iron-icon {
         margin: 0;
       }
+      #moreMessage,
       .hidden {
         display: none;
       }
       @media screen and (max-width: 50em) {
-        #mainContent,
-        section,
-        gr-button,
-        gr-dropdown {
-          display: block;
-          flex: 1;
+        #mainContent {
+          flex-wrap: wrap;
+        }
+        gr-button {
+          --gr-button: {
+            padding: .5em;
+            white-space: nowrap;
+          }
         }
         gr-button,
         gr-dropdown {
-          /* px because don't have the same font size */
-          margin: 0 0 6px 0;
+          margin: 0;
         }
         #actionLoadingMessage {
-          display: block;
           margin: .5em;
           text-align: center;
         }
-        #mainContent.mobileOverlayOpened {
-          display: none;
+        #moreMessage {
+          display: inline;
         }
       }
     </style>
@@ -154,6 +165,7 @@
           disabled-ids="[[_disabledMenuActions]]"
           items="[[_menuActions]]">
           <iron-icon icon="gr-icons:more-vert"></iron-icon>
+          <span id="moreMessage">More</span>
         </gr-dropdown>
     </div>
     <gr-overlay id="overlay" with-backdrop>
@@ -191,7 +203,14 @@
           on-confirm="_handleAbandonDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-abandon-dialog>
-      <gr-confirm-dialog id="createFollowUpDialog"
+      <gr-confirm-submit-dialog
+          id="confirmSubmitDialog"
+          class="confirmDialog"
+          change="[[change]]"
+          action="[[revisionActions.submit]]"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleSubmitConfirm" hidden></gr-confirm-submit-dialog>
+      <gr-dialog id="createFollowUpDialog"
           class="confirmDialog"
           confirm-label="Create"
           on-confirm="_handleCreateFollowUpChange"
@@ -207,8 +226,8 @@
               repo-name="[[change.project]]"
               private-by-default="[[privateByDefault]]"></gr-create-change-dialog>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="confirmDeleteDialog"
           class="confirmDialog"
           confirm-label="Delete"
@@ -221,8 +240,8 @@
         <div class="main" slot="main">
           Do you really want to delete the change?
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="confirmDeleteEditDialog"
           class="confirmDialog"
           confirm-label="Delete"
@@ -235,26 +254,7 @@
         <div class="main" slot="main">
           Do you really want to delete the edit?
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
-          id="confirmSubmitDialog"
-          class="confirmDialog"
-          confirm-label="Submit"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleSubmitConfirm">
-        <div class="header" slot="header">
-          Submit Change
-        </div>
-        <div class="main" slot="main">
-          <p>
-            Are you sure you want to to <strong>submit</strong> this change?
-          </p>
-          <p class="changeSubject">
-            <strong>[[change.subject]]</strong>
-          </p>
-        </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
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 7ae2ef6..6af94a1 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
@@ -212,6 +212,12 @@
      * @event show-alert
      */
 
+    /**
+     * Fires when a change action fails.
+     *
+     * @event show-error
+     */
+
     properties: {
       /**
        * @type {{
@@ -391,7 +397,7 @@
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
       '_changeChanged(change)',
       '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-          'editBasedOnCurrentPatchSet, actions.*, change)',
+          'editBasedOnCurrentPatchSet, actions.*, change.*)',
     ],
 
     listeners: {
@@ -558,73 +564,63 @@
       }
     },
 
+      /**
+       * @param {string=} actionName
+       */
+    _deleteAndNotify(actionName) {
+      if (this.actions[actionName]) {
+        delete this.actions[actionName];
+        this.notifyPath('actions.' + actionName);
+      }
+    },
+
     _editStatusChanged(editMode, editPatchsetLoaded,
         editBasedOnCurrentPatchSet) {
-      const changeActions = this.actions;
-
       if (editPatchsetLoaded) {
         // Only show actions that mutate an edit if an actual edit patch set
         // is loaded.
         if (this.changeIsOpen(this.change.status)) {
           if (editBasedOnCurrentPatchSet) {
-            if (!changeActions.publishEdit) {
+            if (!this.actions.publishEdit) {
               this.set('actions.publishEdit', PUBLISH_EDIT);
             }
-            if (changeActions.rebaseEdit) {
-              delete this.actions.rebaseEdit;
-              this.notifyPath('actions.rebaseEdit');
-            }
+            this._deleteAndNotify('rebaseEdit');
           } else {
-            if (!changeActions.rebaseEdit) {
+            if (!this.actions.rebaseEdit) {
               this.set('actions.rebaseEdit', REBASE_EDIT);
             }
-            if (changeActions.publishEdit) {
-              delete this.actions.publishEdit;
-              this.notifyPath('actions.publishEdit');
-            }
+            this._deleteAndNotify('publishEdit');
           }
         }
-        if (!changeActions.deleteEdit) {
+        if (!this.actions.deleteEdit) {
           this.set('actions.deleteEdit', DELETE_EDIT);
         }
       } else {
-        if (changeActions.publishEdit) {
-          delete this.actions.publishEdit;
-          this.notifyPath('actions.publishEdit');
-        }
-        if (changeActions.rebaseEdit) {
-          delete this.actions.rebaseEdit;
-          this.notifyPath('actions.rebaseEdit');
-        }
-        if (changeActions.deleteEdit) {
-          delete this.actions.deleteEdit;
-          this.notifyPath('actions.deleteEdit');
-        }
+        this._deleteAndNotify('publishEdit');
+        this._deleteAndNotify('rebaseEdit');
+        this._deleteAndNotify('deleteEdit');
       }
 
       if (this.changeIsOpen(this.change.status)) {
         // Only show edit button if there is no edit patchset loaded and the
         // file list is not in edit mode.
         if (editPatchsetLoaded || editMode) {
-          if (changeActions.edit) {
-            delete this.actions.edit;
-            this.notifyPath('actions.edit');
-          }
+          this._deleteAndNotify('edit');
         } else {
-          if (!changeActions.edit) { this.set('actions.edit', EDIT); }
+          if (!this.actions.edit) { this.set('actions.edit', EDIT); }
         }
         // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
         // is loaded.
         if (editMode && !editPatchsetLoaded) {
-          if (!changeActions.stopEdit) {
+          if (!this.actions.stopEdit) {
             this.set('actions.stopEdit', STOP_EDIT);
           }
         } else {
-          if (changeActions.stopEdit) {
-            delete this.actions.stopEdit;
-            this.notifyPath('actions.stopEdit');
-          }
+          this._deleteAndNotify('stopEdit');
         }
+      } else {
+        // Remove edit button.
+        this._deleteAndNotify('edit');
       }
     },
 
@@ -748,7 +744,7 @@
         } else if (!values.includes(a)) {
           return;
         }
-        actions[a].label = this._getActionLabel(actions[a], type);
+        actions[a].label = this._getActionLabel(actions[a]);
 
         // Triggers a re-render by ensuring object inequality.
         result.push(Object.assign({}, actions[a]));
@@ -778,15 +774,15 @@
      * Given a change action, return a display label that uses the appropriate
      * casing or includes explanatory details.
      */
-    _getActionLabel(action, type) {
-      if (action.label === 'Delete' && type === ActionType.CHANGE) {
+    _getActionLabel(action) {
+      if (action.label === 'Delete') {
         // This label is common within change and revision actions. Make it more
         // explicit to the user.
         return 'Delete change';
-      } else if (action.label === 'WIP' && type === ActionType.CHANGE) {
+      } else if (action.label === 'WIP') {
         return 'Mark as work in progress';
       }
-      // Otherwise, just map the anme to sentence case.
+      // Otherwise, just map the name to sentence case.
       return this._toSentenceCase(action.label);
     },
 
@@ -832,7 +828,12 @@
 
     _handleActionTap(e) {
       e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
+      let el = Polymer.dom(e).localTarget;
+      while (el.is !== 'gr-button') {
+        if (!el.parentElement) { return; }
+        el = el.parentElement;
+      }
+
       const key = el.getAttribute('data-action-key');
       if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
           key.indexOf('~') !== -1) {
@@ -1119,8 +1120,7 @@
     _setLabelValuesOnRevert(newChangeId) {
       const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
       if (!labels) { return Promise.resolve(); }
-      return this.$.restAPI.getChangeURLAndSend(newChangeId,
-          this.actions.revert.method, 'current', '/review', {labels});
+      return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
     },
 
     _handleResponse(action, response) {
@@ -1160,7 +1160,7 @@
 
     _handleResponseError(response) {
       return response.text().then(errText => {
-        this.fire('show-alert',
+        this.fire('show-error',
             {message: `Could not perform action: ${errText}`});
         if (!errText.startsWith('Change is already up to date')) {
           throw Error(errText);
@@ -1203,8 +1203,8 @@
               return Promise.resolve();
             }
             const patchNum = revisionAction ? this.latestPatchNum : null;
-            return this.$.restAPI.getChangeURLAndSend(this.changeNum, method,
-                patchNum, actionEndpoint, payload, handleError)
+            return this.$.restAPI.executeChangeAction(this.changeNum, method,
+                actionEndpoint, patchNum, payload, handleError)
                 .then(response => {
                   cleanupFn.call(this);
                   return response;
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 f8c522d..91b39de 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
@@ -252,8 +252,8 @@
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', done => {
-      element.$.confirmSubmitDialog.$.confirm.focus = done;
+    test('submit change', () => {
+      const showSpy = sandbox.spy(element, '_showActionDialog');
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sandbox.stub(element, 'fetchChangeUpdates',
@@ -270,6 +270,30 @@
       const submitButton = element.$$('gr-button[data-action-key="submit"]');
       assert.ok(submitButton);
       MockInteractions.tap(submitButton);
+
+      flushAsynchronousOperations();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', done => {
+      sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
+      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => { return Promise.resolve({isLatest: true}); });
+      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitIcon =
+          element.$$('gr-button[data-action-key="submit"] iron-icon');
+      assert.ok(submitIcon);
+      MockInteractions.tap(submitIcon);
     });
 
     test('_handleSubmitConfirm', () => {
@@ -401,6 +425,19 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
+    test('_setLabelValuesOnRevert', () => {
+      const labels = {'Foo': 1, 'Bar-Baz': -2};
+      const changeId = 1234;
+      sandbox.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveChangeReview')
+          .returns(Promise.resolve());
+      return element._setLabelValuesOnRevert(changeId).then(() => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+      });
+    });
+
     suite('change edits', () => {
       test('shows confirm dialog for delete edit', () => {
         element.set('editMode', true);
@@ -490,6 +527,11 @@
 
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
         assert.isOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        element.change = {status: 'MERGED'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
+        element.change = {status: 'NEW'};
         element.set('editMode', false);
         flushAsynchronousOperations();
 
@@ -1328,6 +1370,7 @@
     suite('_send', () => {
       let cleanup;
       let payload;
+      let onShowError;
       let onShowAlert;
 
       setup(() => {
@@ -1336,6 +1379,8 @@
         element.latestPatchNum = 12;
         payload = {foo: 'bar'};
 
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
         element.addEventListener('show-alert', onShowAlert);
       });
@@ -1346,27 +1391,27 @@
         setup(() => {
           sandbox.stub(element, 'fetchChangeUpdates')
               .returns(Promise.resolve({isLatest: true}));
-          sendStub = sandbox.stub(element.$.restAPI, 'getChangeURLAndSend')
+          sendStub = sandbox.stub(element.$.restAPI, 'executeChangeAction')
               .returns(Promise.resolve({}));
         });
 
         test('change action', () => {
           return element._send('DELETE', payload, '/endpoint', false, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', null,
-                    '/endpoint', payload));
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    null, payload));
               });
         });
 
         test('revision action', () => {
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', 12, '/endpoint',
-                    payload));
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    12, payload));
               });
         });
       });
@@ -1376,11 +1421,12 @@
           sandbox.stub(element, 'fetchChangeUpdates')
               .returns(Promise.resolve({isLatest: false}));
           const sendStub = sandbox.stub(element.$.restAPI,
-              'getChangeURLAndSend');
+              'executeChangeAction');
 
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
                 assert.isTrue(onShowAlert.calledOnce);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isFalse(sendStub.called);
               });
@@ -1390,7 +1436,7 @@
           sandbox.stub(element, 'fetchChangeUpdates')
               .returns(Promise.resolve({isLatest: true}));
           const sendStub = sandbox.stub(element.$.restAPI,
-              'getChangeURLAndSend',
+              'executeChangeAction',
               (num, method, patchNum, endpoint, payload, onErr) => {
                 onErr();
                 return Promise.resolve(null);
@@ -1399,7 +1445,7 @@
 
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.called);
                 assert.isTrue(sendStub.calledOnce);
                 assert.isTrue(handleErrorStub.called);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
index ff9e547..b056233 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -145,7 +145,6 @@
         sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
         Gerrit._setPluginsPending([]);
         element = createElement();
-        sandbox.stub(element, '_computeCanDeleteVote').returns(true);
 
         labelChangeStub = sandbox.stub();
         plugin.changeMetadata().onLabelsChanged(labelChangeStub);
@@ -156,8 +155,18 @@
         assert.equal(labelChangeStub.callCount, 1);
         assert.isTrue(labelChangeStub.calledWithExactly(labels));
         assert.equal(labelChangeStub.args[0][0]['CI'].all.length, 2);
-        MockInteractions.tap(Polymer.dom(element.root).querySelector(
-            'gr-account-chip').$.remove);
+        element.set(['change', 'labels'], {
+          CI: {
+            all: [
+              {value: 1, name: 'user 2', _account_id: 1},
+            ],
+            values: {
+              ' 0': 'Don\'t submit as-is',
+              '+1': 'No score',
+              '+2': 'Looks good to me',
+            },
+          },
+        });
         // Wait for fake rest API response.
         flush(() => {
           assert.equal(labelChangeStub.callCount, 2);
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 cd7ec2b..8413388 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
@@ -27,7 +27,6 @@
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.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-label/gr-label.html">
 <link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
@@ -38,24 +37,38 @@
 
 <dom-module id="gr-change-metadata">
   <template>
-    <style include="gr-voting-styles"></style>
     <style include="shared-styles">
-      .hideDisplay {
-        display: none;
+      :host {
+        display: table;
+      }
+      section {
+        display: table-row;
+      }
+      section:not(:first-of-type) .title,
+      section:not(:first-of-type) .value {
+        padding-top: .5em;
       }
       section:not(:first-of-type) {
         margin-top: 1em;
       }
       .title,
       .value {
-        display: block;
+        display: table-cell;
       }
       .title {
         color: var(--deemphasized-text-color);
         font-family: var(--font-family-bold);
         max-width: 20em;
+        padding-left: var(--metadata-horizontal-padding);
+        padding-right: .5em;
         word-break: break-word;
       }
+      .value {
+        padding-right: var(--metadata-horizontal-padding);
+      }
+      gr-change-requirements {
+        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+      }
       gr-account-link {
         max-width: 20ch;
         overflow: hidden;
@@ -66,35 +79,6 @@
       gr-editable-label {
         max-width: 9em;
       }
-      .labelValueContainer:not(:first-of-type) {
-        margin-top: .25em;
-      }
-      .labelValueContainer span {
-        align-items: baseline;
-        display: inline-flex;
-      }
-      .labelValueContainer {
-        border-radius: 3px;
-        padding: .1em .3em;
-      }
-      gr-label {
-        margin-right: .3em;
-        padding: .05em .85em;
-        text-align: center;
-        @apply --vote-chip-styles;
-      }
-      .max {
-        background-color: var(--vote-color-approved);
-      }
-      .min {
-        background-color: var(--vote-color-rejected);
-      }
-      .positive {
-        background-color: var(--vote-color-recommended);
-      }
-      .negative {
-        background-color: var(--vote-color-disliked);
-      }
       .webLink {
         display: block;
       }
@@ -126,6 +110,7 @@
       .parentList gr-commit-info {
         display: inline-block;
       }
+      .hideDisplay,
       #parentNotCurrentMessage {
         display: none;
       }
@@ -133,24 +118,10 @@
         --arrow-color: #ffa62f;
         display: inline-block;
       }
-      @media screen and (max-width: 50em), screen and (min-width: 75em) {
-        :host {
-          display: table;
-        }
-        section {
-          display: table-row;
-        }
-        section:not(:first-of-type) .title,
-        section:not(:first-of-type) .value {
-          padding-top: .5em;
-        }
-        .title,
-        .value {
-          display: table-cell;
-        }
-        .title {
-          padding-right: .5em;
-        }
+      .separatedSection {
+        border-top: 1px solid var(--border-color);
+        margin-top: .5em;
+        padding: .5em 0;
       }
     </style>
     <gr-external-style id="externalStyle" name="change-metadata">
@@ -181,10 +152,10 @@
           <gr-account-list
               max-count="1"
               id="assigneeValue"
-              placeholder="Add assignee..."
+              placeholder="Set assignee..."
               accounts="{{_assignee}}"
               change="[[change]]"
-              readonly="[[_computeAssigneeReadOnly(mutable, change)]]"
+              readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
               allow-any-user></gr-account-list>
         </span>
       </section>
@@ -194,8 +165,9 @@
           <span class="value">
             <gr-reviewer-list
                 change="{{change}}"
-                mutable="[[mutable]]"
-                reviewers-only></gr-reviewer-list>
+                mutable="[[_mutable]]"
+                reviewers-only
+                max-reviewers-displayed="3"></gr-reviewer-list>
           </span>
         </section>
         <section>
@@ -203,9 +175,9 @@
           <span class="value">
             <gr-reviewer-list
                 change="{{change}}"
-                mutable="[[mutable]]"
+                mutable="[[_mutable]]"
                 ccs-only
-                max-reviewers-displayed="5"></gr-reviewer-list>
+                max-reviewers-displayed="3"></gr-reviewer-list>
           </span>
         </section>
       </template>
@@ -215,12 +187,12 @@
           <span class="value">
             <gr-reviewer-list
                 change="{{change}}"
-                mutable="[[mutable]]"></gr-reviewer-list>
+                mutable="[[_mutable]]"></gr-reviewer-list>
           </span>
         </section>
       </template>
       <section>
-        <span class="title">Project</span>
+        <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectURL(change.project)]]">
             <gr-limited-text limit="40" text="[[change.project]]"></gr-limited-text>
@@ -298,53 +270,24 @@
                   on-remove="_handleHashtagRemoved">
               </gr-linked-chip>
             </template>
-            <gr-editable-label
-                uppercase
-                label-text="Add a hashtag"
-                value="{{_newHashtag}}"
-                placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-                read-only="[[_hashtagReadOnly]]"
-                on-changed="_handleHashtagChanged"></gr-editable-label>
-          </span>
-        </section>
-      </template>
-      <template is="dom-repeat"
-          items="[[_computeLabelNames(labels)]]" as="labelName">
-        <section>
-          <span class="title">[[labelName]]</span>
-          <span class="value">
-            <template is="dom-repeat"
-                items="[[_computeLabelValues(labelName, labels.*)]]"
-                as="label">
-              <div class="labelValueContainer">
-                <span>
-                  <gr-label
-                      has-tooltip
-                      title="[[_computeValueTooltip(change, label.value, labelName)]]"
-                      class$="[[label.className]] voteChip">
-                    [[label.value]]
-                  </gr-label>
-                  <gr-account-chip
-                      account="[[label.account]]"
-                      data-account-id$="[[label.account._account_id]]"
-                      label-name="[[labelName]]"
-                      removable="[[_computeCanDeleteVote(label.account, mutable)]]"
-                      transparent-background
-                      on-remove="_onDeleteVote"></gr-account-chip>
-                </span>
-              </div>
+            <template is="dom-if" if="[[!_hashtagReadOnly]]">
+              <gr-editable-label
+                  uppercase
+                  label-text="Add a hashtag"
+                  value="{{_newHashtag}}"
+                  placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
+                  read-only="[[_hashtagReadOnly]]"
+                  on-changed="_handleHashtagChanged"></gr-editable-label>
             </template>
           </span>
         </section>
       </template>
-      <template is="dom-if" if="[[_showRequirements]]">
-        <section class="requirementsStatus">
-          <span class="title">Submit Status</span>
-          <span class="value">
-            <gr-change-requirements change="[[change]]"></gr-change-requirements>
-          </span>
-        </section>
-      </template>
+      <div class="separatedSection">
+        <gr-change-requirements
+            change="{{change}}"
+            account="[[account]]"
+            mutable="[[_mutable]]"></gr-change-requirements>
+      </div>
       <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
         <span class="title">Links</span>
         <span class="value">
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 aaada4f..8b119d8 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
@@ -46,10 +46,14 @@
         type: Object,
         notify: true,
       },
+      account: Object,
       /** @type {?} */
       revision: Object,
       commitInfo: Object,
-      mutable: Boolean,
+      _mutable: {
+        type: Boolean,
+        computed: '_computeIsMutable(account)',
+      },
       /**
        * @type {{ note_db_enabled: string }}
        */
@@ -62,11 +66,11 @@
       },
       _topicReadOnly: {
         type: Boolean,
-        computed: '_computeTopicReadOnly(mutable, change)',
+        computed: '_computeTopicReadOnly(_mutable, change)',
       },
       _hashtagReadOnly: {
         type: Boolean,
-        computed: '_computeHashtagReadOnly(mutable, change)',
+        computed: '_computeHashtagReadOnly(_mutable, change)',
       },
       _showReviewersByState: {
         type: Boolean,
@@ -156,60 +160,6 @@
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues(labelName, _labels) {
-      const result = [];
-      const labels = _labels.base;
-      const labelInfo = labels[labelName];
-      if (!labelInfo) { return result; }
-      if (!labelInfo.values) {
-        if (labelInfo.rejected || labelInfo.approved) {
-          const ok = labelInfo.approved || !labelInfo.rejected;
-          return [{
-            value: ok ? '👍️' : '👎️',
-            className: ok ? 'positive' : 'negative',
-            account: ok ? labelInfo.approved : labelInfo.rejected,
-          }];
-        }
-        return result;
-      }
-      const approvals = labelInfo.all || [];
-      const values = Object.keys(labelInfo.values);
-      for (const label of approvals) {
-        if (label.value && label.value != labels[labelName].default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            if (parseInt(label.value, 10) ===
-                parseInt(values[values.length - 1], 10)) {
-              labelClassName = 'max';
-            } else {
-              labelClassName = 'positive';
-            }
-          } else if (label.value < 0) {
-            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
-              labelClassName = 'min';
-            } else {
-              labelClassName = 'negative';
-            }
-          }
-          result.push({
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          });
-        }
-      }
-      return result;
-    },
-
-    _computeValueTooltip(change, score, labelName) {
-      if (!change.labels[labelName] ||
-          !change.labels[labelName].values ||
-          !change.labels[labelName].values[score]) { return ''; }
-      return change.labels[labelName].values[score];
-    },
-
     _handleTopicChanged(e, topic) {
       const lastTopic = this.change.topic;
       if (!topic.length) { topic = null; }
@@ -298,75 +248,6 @@
       return hasRequirements || hasLabels || !!change.work_in_progress;
     },
 
-    /**
-     * A user is able to delete a vote iff the mutable property is true and the
-     * reviewer that left the vote exists in the list of removable_reviewers
-     * received from the backend.
-     *
-     * @param {!Object} reviewer An object describing the reviewer that left the
-     *     vote.
-     * @param {boolean} mutable this.mutable describes whether the
-     *     change-metadata section is modifiable by the current user.
-     */
-    _computeCanDeleteVote(reviewer, mutable) {
-      if (!mutable || !this.change || !this.change.removable_reviewers) {
-        return false;
-      }
-      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-        if (this.change.removable_reviewers[i]._account_id ===
-            reviewer._account_id) {
-          return true;
-        }
-      }
-      return false;
-    },
-
-    /**
-     * Closure annotation for Polymer.prototype.splice is off.
-     * For now, supressing annotations.
-     *
-     * TODO(beckysiegel) submit Polymer PR
-     *
-     * @suppress {checkTypes} */
-    _onDeleteVote(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      target.disabled = true;
-      const labelName = target.labelName;
-      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
-      this._xhrPromise =
-          this.$.restAPI.deleteVote(this.change._number, accountID, labelName)
-          .then(response => {
-            target.disabled = false;
-            if (!response.ok) { return response; }
-            const label = this.change.labels[labelName];
-            const labels = label.all || [];
-            let wasChanged = false;
-            for (let i = 0; i < labels.length; i++) {
-              if (labels[i]._account_id === accountID) {
-                for (const key in label) {
-                  if (label.hasOwnProperty(key) &&
-                      label[key]._account_id === accountID) {
-                    // Remove special label field, keeping change label values
-                    // in sync with the backend.
-                    this.change.labels[labelName][key] = null;
-                    wasChanged = true;
-                  }
-                }
-                this.change.labels[labelName].all.splice(i, 1);
-                wasChanged = true;
-                break;
-              }
-            }
-            if (wasChanged) {
-              this.notifyPath('change.labels');
-            }
-          }).catch(err => {
-            target.disabled = false;
-            return;
-          });
-    },
-
     _computeProjectURL(project) {
       return Gerrit.Nav.getUrlForProjectChanges(project);
     },
@@ -458,5 +339,9 @@
         parentIsCurrent ? 'current' : 'notCurrent',
       ].join(' ');
     },
+
+    _computeIsMutable(account) {
+      return !!Object.keys(account).length;
+    },
   });
 })();
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 1330451..17c3d70 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
@@ -296,32 +296,6 @@
           element._computeShowUploaderHide(change), 'hideDisplay');
     });
 
-    test('_computeValueTooltip', () => {
-      // Existing label.
-      const change = {labels: {'Foo-bar': {values: {0: 'Baz'}}}};
-      let score = '0';
-      let labelName = 'Foo-bar';
-      let actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, 'Baz');
-
-      // Non-extsistent label.
-      labelName = 'xyz';
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
-
-      // Non-extsistent score.
-      score = '2';
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
-
-      // No values on label.
-      labelName = 'abcd';
-      score = '0';
-      change.labels.abcd = {};
-      actual = element._computeValueTooltip(change, score, labelName);
-      assert.equal(actual, '');
-    });
-
     test('_computeParents', () => {
       const parents = [{commit: '123', subject: 'abc'}];
       assert.isUndefined(element._computeParents(
@@ -403,7 +377,7 @@
       });
 
       test('topic read only hides delete button', () => {
-        element.mutable = false;
+        element.account = {};
         element.change = change;
         flushAsynchronousOperations();
         const button = element.$$('gr-linked-chip').$$('gr-button');
@@ -411,7 +385,7 @@
       });
 
       test('topic not read only does not hide delete button', () => {
-        element.mutable = true;
+        element.account = {test: true};
         change.actions.topic.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
@@ -463,7 +437,7 @@
           note_db_enabled: true,
         };
         flushAsynchronousOperations();
-        element.mutable = false;
+        element.account = {};
         element.change = change;
         flushAsynchronousOperations();
         const button = element.$$('gr-linked-chip').$$('gr-button');
@@ -475,7 +449,7 @@
           note_db_enabled: true,
         };
         flushAsynchronousOperations();
-        element.mutable = true;
+        element.account = {test: true};
         change.actions.hashtags.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
@@ -486,7 +460,6 @@
 
     suite('remove reviewer votes', () => {
       setup(() => {
-        sandbox.stub(element, '_computeValueTooltip').returns('');
         sandbox.stub(element, '_computeTopicReadOnly').returns(true);
         element.change = {
           _number: 42,
@@ -507,44 +480,56 @@
         flushAsynchronousOperations();
       });
 
-      test('_computeCanDeleteVote hides delete button', () => {
-        const button = element.$$('gr-account-chip').$$('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
-        element.mutable = true;
-        assert.isTrue(button.hasAttribute('hidden'));
-      });
-
-      test('_computeCanDeleteVote shows delete button', () => {
-        element.change.removable_reviewers = [
-          {
-            _account_id: 1,
-            name: 'bojack',
-          },
-        ];
-        element.mutable = true;
-        const button = element.$$('gr-account-chip').$$('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
-      });
-
-      test('deletes votes', () => {
-        const deleteResponse = Promise.resolve({ok: true});
-        const deleteStub = sandbox.stub(
-            element.$.restAPI, 'deleteVote').returns(deleteResponse);
-
-        element.change.removable_reviewers = [{
+      suite('assignee field', () => {
+        const dummyAccount = {
           _account_id: 1,
           name: 'bojack',
-        }];
-        element.change.labels.test.recommended = {_account_id: 1};
-        element.mutable = true;
-        const chip = element.$$('gr-account-chip');
-        const button = chip.$$('gr-button');
-        MockInteractions.tap(button);
-        assert.isTrue(chip.disabled);
-        return deleteResponse.then(() => {
-          assert.isFalse(chip.disabled);
-          assert.notOk(element.change.labels.test.recommended);
-          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
+        };
+        const change = {
+          actions: {
+            assignee: {enabled: false},
+          },
+          assignee: dummyAccount,
+        };
+        let deleteStub;
+        let setStub;
+
+        setup(() => {
+          deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
+          setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
+        });
+
+        test('changing change recomputes _assignee', () => {
+          assert.isFalse(!!element._assignee.length);
+          const change = element.change;
+          change.assignee = dummyAccount;
+          element._changeChanged(change);
+          assert.deepEqual(element._assignee[0], dummyAccount);
+        });
+
+        test('modifying _assignee calls API', () => {
+          assert.isFalse(!!element._assignee.length);
+          element.set('_assignee', [dummyAccount]);
+          assert.isTrue(setStub.calledOnce);
+          assert.deepEqual(element.change.assignee, dummyAccount);
+          element.set('_assignee', [dummyAccount]);
+          assert.isTrue(setStub.calledOnce);
+          element.set('_assignee', []);
+          assert.isTrue(deleteStub.calledOnce);
+          assert.equal(element.change.assignee, undefined);
+          element.set('_assignee', []);
+          assert.isTrue(deleteStub.calledOnce);
+        });
+
+        test('_computeAssigneeReadOnly', () => {
+          let mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          mutable = true;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          change.actions.assignee.enabled = true;
+          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+          mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
         });
       });
 
@@ -600,59 +585,6 @@
               assert.equal(element.change.hashtags, newHashtag);
             });
       });
-
-      suite('assignee field', () => {
-        const dummyAccount = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        const change = {
-          actions: {
-            assignee: {enabled: false},
-          },
-          assignee: dummyAccount,
-        };
-        let deleteStub;
-        let setStub;
-
-        setup(() => {
-          deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
-          setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
-        });
-
-        test('changing change recomputes _assignee', () => {
-          assert.isFalse(!!element._assignee.length);
-          const change = element.change;
-          change.assignee = dummyAccount;
-          element._changeChanged(change);
-          assert.deepEqual(element._assignee[0], dummyAccount);
-        });
-
-        test('modifying _assignee calls API', () => {
-          assert.isFalse(!!element._assignee.length);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          assert.deepEqual(element.change.assignee, dummyAccount);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-          assert.equal(element.change.assignee, undefined);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-        });
-
-        test('_computeAssigneeReadOnly', () => {
-          let mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          mutable = true;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          change.actions.assignee.enabled = true;
-          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-          mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        });
-      });
     });
 
     suite('plugin endpoints', () => {
@@ -678,105 +610,5 @@
         });
       });
     });
-
-    suite('label colors', () => {
-      test('valueless label rejected', () => {
-        element.change = {
-          labels: {
-            'Do-Not-Submit': {
-              rejected: {name: 'someone'},
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('negative'));
-      });
-
-      test('valueless label approved', () => {
-        element.change = {
-          labels: {
-            'To-The-Infinity': {
-              approved: {name: 'someone'},
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-      });
-
-      test('-2 to +2', () => {
-        element.change = {
-          labels: {
-            'Code-Review': {
-              all: [
-                {value: 2, name: 'user 2'},
-                {value: 1, name: 'user 1'},
-                {value: -1, name: 'user 3'},
-                {value: -2, name: 'user 4'},
-              ],
-              values: {
-                '-2': 'Awful',
-                '-1': 'Don\'t submit as-is',
-                ' 0': 'No score',
-                '+1': 'Looks good to me',
-                '+2': 'Ready to submit',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-        assert.isTrue(labels[2].classList.contains('negative'));
-        assert.isTrue(labels[3].classList.contains('min'));
-      });
-
-      test('-1 to +1', () => {
-        element.change = {
-          labels: {
-            CI: {
-              all: [
-                {value: 1, name: 'user 1'},
-                {value: -1, name: 'user 2'},
-              ],
-              values: {
-                '-1': 'Don\'t submit as-is',
-                ' 0': 'No score',
-                '+1': 'Looks good to me',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('min'));
-      });
-
-      test('0 to +2', () => {
-        element.change = {
-          labels: {
-            CI: {
-              all: [
-                {value: 1, name: 'user 2'},
-                {value: 2, name: 'user '},
-              ],
-              values: {
-                ' 0': 'Don\'t submit as-is',
-                '+1': 'No score',
-                '+2': 'Looks good to me',
-              },
-            },
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-        assert.isTrue(labels[1].classList.contains('max'));
-      });
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index 439677a..1431887 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
@@ -18,60 +18,153 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
+<link rel="import" href="../../shared/gr-label-info/gr-label-info.html">
+<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 
 <dom-module id="gr-change-requirements">
   <template strip-whitespace>
     <style include="shared-styles">
+      :host {
+        display: table;
+        width: 100%;
+      }
       .status {
+        color: #FFA62F;
         display: inline-block;
-        font-weight: initial;
+        font-family: var(--monospace-font-family);
         text-align: center;
       }
-      .unsatisfied .icon {
-        color: #FFA62F;
+      .approved.status {
+        color: var(--vote-text-color-recommended);
       }
-      .satisfied .icon {
-        color: #388E3C;
+      .rejected.status {
+        color: var(--vote-text-color-disliked);
       }
-      .requirement {
-        padding: .1em 0;
+      iron-icon {
+        color: inherit;
       }
-      .requirementContainer:not(:first-of-type) {
-        margin-top: .25em;
+      .name {
+        font-family: var(--font-family-bold);
       }
-      .labelName, .changeIsWip {
-        font-weight: bold;
+      section {
+        display: table-row;
+      }
+      .show-hide {
+        float: right;
+      }
+      .title {
+        min-width: 10em;
+        padding: .75em .5em 0 var(--requirements-horizontal-padding);
+        vertical-align: top;
+      }
+      .value {
+        padding: .6em .5em 0 0;
+        vertical-align: middle;
+      }
+      .title,
+      .value {
+        display: table-cell;
+      }
+      .hidden {
+        display: none;
+      }
+      .showHide {
+        cursor: pointer;
+      }
+      .showHide .title {
+        border-top: 1px solid var(--border-color);
+        padding-bottom: .5em;
+        padding-top: .5em;
+      }
+      .showHide .value {
+        border-top: 1px solid var(--border-color);
+        padding-top: 0;
+        vertical-align: middle;
+      }
+      .showHide iron-icon {
+        color: var(--deemphasized-text-color);
+        float: right;
+      }
+      .spacer {
+        height: .5em;
       }
     </style>
-    <template is="dom-if" if="[[_showWip]]">
-      <div class="requirement unsatisfied changeIsWip">
-        <span class="status"><iron-icon class="icon" icon="gr-icons:hourglass"></iron-icon></span>
-        Work in Progress
-      </div>
-    </template>
-    <template is="dom-if" if="[[_showLabels]]">
-      <template
-          is="dom-repeat"
-          items="[[labels]]">
-        <div class$="requirement [[item.style]]">
-          <span class="status">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+    <template
+        is="dom-repeat"
+        items="[[_requirements]]">
+      <section>
+        <div class="title requirement">
+          <span class$="status [[item.style]]">
+            <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
           </span>
-          Label <span class="labelName">[[item.label]]</span>
+          <gr-limited-text class="name" limit="40" text="[[item.fallback_text]]"></gr-limited-text>
         </div>
-      </template>
+      </section>
     </template>
     <template
         is="dom-repeat"
-        items="[[requirements]]">
-      <div class$="requirement [[_computeRequirementClass(item.satisfied)]]">
-        <span class="status">
-          <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
-        </span>
-        [[item.fallback_text]]
-      </div>
+        items="[[_requiredLabels]]">
+      <section>
+        <div class="title">
+          <span class$="status [[item.style]]">
+            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+          </span>
+          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
+        </div>
+        <div class="value">
+          <gr-label-info
+              change="{{change}}"
+              account="[[account]]"
+              mutable="[[mutable]]"
+              label="[[item.label]]"
+              label-info="[[item.labelInfo]]"></gr-label-info>
+        </div>
+      </section>
     </template>
+    <section class="spacer"></section>
+    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
+    <section
+        show-bottom-border$="[[_showOptionalLabels]]"
+        on-tap="_handleShowHide"
+        class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
+      <div class="title">Other labels</div>
+      <div class="value">
+        <iron-icon
+            id="showHide"
+            icon="[[_computeShowHideIcon(_showOptionalLabels)]]">
+        </iron-icon>
+      </label>
+      </div>
+    </section>
+    <template
+        is="dom-repeat"
+        items="[[_optionalLabels]]">
+      <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
+        <div class="title">
+          <span class$="status [[item.style]]">
+            <template is="dom-if" if="[[item.icon]]">
+              <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+            </template>
+            <template is="dom-if" if="[[!item.icon]]">
+              <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
+            </template>
+          </span>
+          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
+        </div>
+        <div class="value">
+          <gr-label-info
+              change="{{change}}"
+              account="[[account]]"
+              mutable="[[mutable]]"
+              label="[[item.label]]"
+              label-info="[[item.labelInfo]]"></gr-label-info>
+        </div>
+      </section>
+    </template>
+    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"></section>
   </template>
   <script src="gr-change-requirements.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 765f639..8ec00dd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -23,21 +23,27 @@
     properties: {
       /** @type {?} */
       change: Object,
-      requirements: {
+      account: Object,
+      mutable: Boolean,
+      _requirements: {
         type: Array,
         computed: '_computeRequirements(change)',
       },
-      labels: {
+      _requiredLabels: {
         type: Array,
-        computed: '_computeLabels(change)',
+        value: () => [],
+      },
+      _optionalLabels: {
+        type: Array,
+        value: () => [],
       },
       _showWip: {
         type: Boolean,
         computed: '_computeShowWip(change)',
       },
-      _showLabels: {
+      _showOptionalLabels: {
         type: Boolean,
-        computed: '_computeShowLabelStatus(change)',
+        value: true,
       },
     },
 
@@ -45,9 +51,9 @@
       Gerrit.RESTClientBehavior,
     ],
 
-    _computeShowLabelStatus(change) {
-      return change.status === this.ChangeStatus.NEW;
-    },
+    observers: [
+      '_computeLabels(change.labels.*)',
+    ],
 
     _computeShowWip(change) {
       return change.work_in_progress;
@@ -59,44 +65,86 @@
       if (change.requirements) {
         for (const requirement of change.requirements) {
           requirement.satisfied = requirement.status === 'OK';
+          requirement.style =
+              this._computeRequirementClass(requirement.satisfied);
           _requirements.push(requirement);
         }
       }
+      if (change.work_in_progress) {
+        _requirements.push({
+          fallback_text: 'Work-in-progress',
+          tooltip: 'Change must not be in \'Work in Progress\' state.',
+        });
+      }
 
       return _requirements;
     },
 
-    _computeLabels(change) {
-      const labels = change.labels;
-      const _labels = [];
-
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
-        const obj = labels[label];
-        if (obj.optional) { continue; }
-
-        const icon = this._computeRequirementIcon(obj.approved);
-        const style = this._computeRequirementClass(obj.approved);
-        _labels.push({label, icon, style});
-      }
-
-      return _labels;
-    },
-
     _computeRequirementClass(requirementStatus) {
-      if (requirementStatus) {
-        return 'satisfied';
-      } else {
-        return 'unsatisfied';
-      }
+      return requirementStatus ? 'approved' : '';
     },
 
     _computeRequirementIcon(requirementStatus) {
-      if (requirementStatus) {
-        return 'gr-icons:check';
-      } else {
-        return 'gr-icons:hourglass';
+      return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+    },
+
+    _computeLabels(labelsRecord) {
+      const labels = labelsRecord.base;
+      this._optionalLabels = [];
+      this._requiredLabels = [];
+
+      for (const label in labels) {
+        if (!labels.hasOwnProperty(label)) { continue; }
+
+        const labelInfo = labels[label];
+        const icon = this._computeLabelIcon(labelInfo);
+        const style = this._computeLabelClass(labelInfo);
+        const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
+
+        this.push(path, {label, icon, style, labelInfo});
       }
     },
+
+    /**
+     * @param {Object} labelInfo
+     * @return {string} The icon name, or undefined if no icon should
+     *     be used.
+     */
+    _computeLabelIcon(labelInfo) {
+      if (labelInfo.approved) { return 'gr-icons:check'; }
+      if (labelInfo.rejected) { return 'gr-icons:close'; }
+      return 'gr-icons:hourglass';
+    },
+
+    /**
+     * @param {Object} labelInfo
+     */
+    _computeLabelClass(labelInfo) {
+      if (labelInfo.approved) { return 'approved'; }
+      if (labelInfo.rejected) { return 'rejected'; }
+      return '';
+    },
+
+    _computeShowOptional(optionalFieldsRecord) {
+      return optionalFieldsRecord.base.length ? '' : 'hidden';
+    },
+
+    _computeLabelValue(value) {
+      return (value > 0 ? '+' : '') + value;
+    },
+
+    _computeShowHideIcon(showOptionalLabels) {
+      return showOptionalLabels ?
+          'gr-icons:expand-less' :
+          'gr-icons:expand-more';
+    },
+
+    _computeSectionClass(show) {
+      return show ? '' : 'hidden';
+    },
+
+    _handleShowHide(e) {
+      this._showOptionalLabels = !this._showOptionalLabels;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index efac0c2..3f35158 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -40,19 +40,69 @@
       element = fixture('basic');
     });
 
-    test('computed fields', () => {
-      assert.isTrue(element._computeShowLabelStatus({status: 'NEW'}));
-      assert.isFalse(element._computeShowLabelStatus({status: 'MERGED'}));
-      assert.isFalse(element._computeShowLabelStatus({status: 'ABANDONED'}));
-
+    test('requirements computed fields', () => {
       assert.isTrue(element._computeShowWip({work_in_progress: true}));
       assert.isFalse(element._computeShowWip({work_in_progress: false}));
 
-      assert.equal(element._computeRequirementClass(true), 'satisfied');
-      assert.equal(element._computeRequirementClass(false), 'unsatisfied');
+      assert.equal(element._computeRequirementClass(true), 'approved');
+      assert.equal(element._computeRequirementClass(false), '');
 
       assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-      assert.equal(element._computeRequirementIcon(false), 'gr-icons:hourglass');
+      assert.equal(element._computeRequirementIcon(false),
+          'gr-icons:hourglass');
+    });
+
+    test('label computed fields', () => {
+      assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
+      assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
+      assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+
+      assert.equal(element._computeLabelClass({approved: []}), 'approved');
+      assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
+      assert.equal(element._computeLabelClass({}), '');
+      assert.equal(element._computeLabelClass({value: 0}), '');
+
+      assert.equal(element._computeLabelValue(1), '+1');
+      assert.equal(element._computeLabelValue(-1), '-1');
+      assert.equal(element._computeLabelValue(0), '0');
+    });
+
+    test('_computeLabels', () => {
+      assert.equal(element._optionalLabels.length, 0);
+      assert.equal(element._requiredLabels.length, 0);
+      element._computeLabels({base: {
+        test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+          value: 1,
+        },
+        opt_test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+          optional: true,
+        },
+      }});
+      assert.equal(element._optionalLabels.length, 1);
+      assert.equal(element._requiredLabels.length, 1);
+
+      assert.equal(element._optionalLabels[0].label, 'opt_test');
+      assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+      assert.equal(element._optionalLabels[0].style, '');
+      assert.ok(element._optionalLabels[0].labelInfo);
+    });
+
+    test('optional show/hide', () => {
+      element._optionalLabels = [{label: 'test'}];
+      flushAsynchronousOperations();
+
+      assert.ok(element.$$('section.optional'));
+      MockInteractions.tap(element.$$('.showHide'));
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._showOptionalLabels);
+      assert.isTrue(isHidden(element.$$('section.optional')));
     });
 
     test('properly converts satisfied labels', () => {
@@ -60,17 +110,16 @@
         status: 'NEW',
         labels: {
           Verified: {
-            approved: true,
+            approved: [],
           },
         },
         requirements: [],
       };
       flushAsynchronousOperations();
 
-      const labelName = element.$$('.satisfied .labelName');
-      assert.ok(labelName);
-      assert.isFalse(labelName.hasAttribute('hidden'));
-      assert.equal(labelName.innerHTML, 'Verified');
+      assert.ok(element.$$('.approved'));
+      assert.ok(element.$$('.name'));
+      assert.equal(element.$$('.name').text, 'Verified');
     });
 
     test('properly converts unsatisfied labels', () => {
@@ -84,10 +133,10 @@
       };
       flushAsynchronousOperations();
 
-      const labelName = element.$$('.unsatisfied .labelName');
-      assert.ok(labelName);
-      assert.isFalse(labelName.hasAttribute('hidden'));
-      assert.equal(labelName.innerHTML, 'Verified');
+      const name = element.$$('.name');
+      assert.ok(name);
+      assert.isFalse(name.hasAttribute('hidden'));
+      assert.equal(name.text, 'Verified');
     });
 
     test('properly displays Work In Progress', () => {
@@ -99,13 +148,10 @@
       };
       flushAsynchronousOperations();
 
-      const changeIsWip = element.$$('.changeIsWip.unsatisfied');
+      const changeIsWip = element.$$('.title');
       assert.ok(changeIsWip);
-      assert.isFalse(changeIsWip.hasAttribute('hidden'));
-      assert.notEqual(changeIsWip.innerHTML.indexOf('Work in Progress'), -1);
     });
 
-
     test('properly displays a satisfied requirement', () => {
       element.change = {
         status: 'NEW',
@@ -117,13 +163,60 @@
       };
       flushAsynchronousOperations();
 
-      const satisfiedRequirement = element.$$('.satisfied');
-      assert.ok(satisfiedRequirement);
-      assert.isFalse(satisfiedRequirement.hasAttribute('hidden'));
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.isFalse(requirement.hasAttribute('hidden'));
+      assert.ok(requirement.querySelector('.approved'));
+      assert.equal(requirement.querySelector('.name').text,
+          'Resolve all comments');
+    });
 
-      // Extract the content of the text node (second element, after the span)
-      const textNode = satisfiedRequirement.childNodes[1].nodeValue.trim();
-      assert.equal(textNode, 'Resolve all comments');
+    test('satisfied class is applied with OK', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'OK',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.ok(requirement.querySelector('.approved'));
+    });
+
+    test('satisfied class is not applied with NOT_READY', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'NOT_READY',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.strictEqual(requirement.querySelector('.approved'), null);
+    });
+
+    test('satisfied class is not applied with RULE_ERROR', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'RULE_ERROR',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const requirement = element.$$('.requirement');
+      assert.ok(requirement);
+      assert.strictEqual(requirement.querySelector('.approved'), null);
     });
   });
 </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 b26d649..461bfc4 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
@@ -51,6 +51,7 @@
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
 <link rel="import" href="../gr-thread-list/gr-thread-list.html">
+<link rel="import" href="../gr-upload-help-dialog/gr-upload-help-dialog.html">
 
 <dom-module id="gr-change-view">
   <template>
@@ -92,7 +93,7 @@
       .headerTitle .headerSubject {
         font-family: var(--font-family-bold);
       }
-      .replyContainer {
+      #replyBtn {
         margin-bottom: 1em;
       }
       gr-change-star {
@@ -109,21 +110,14 @@
          https://github.com/Polymer/polymer/issues/2531 */
       .container section.changeInfo {
         display: flex;
-        padding: 0 var(--default-horizontal-margin);
       }
       .changeId {
         color: var(--deemphasized-text-color);
         font-family: var(--font-family);
         margin-top: 1em;
       }
-      .changeInfo-column:not(:last-of-type) {
-        margin-right: 1em;
-        padding-right: 1em;
-      }
       .changeMetadata {
         border-right: 1px solid var(--border-color);
-        font-size: .95rem;
-        padding: 1em 0;
       }
       /* Prevent plugin text from overflowing. */
       #change_plugins {
@@ -191,7 +185,6 @@
         overflow: hidden;
       }
       #relatedChanges {
-        font-size: .9rem;
       }
       #relatedChanges.collapsed {
         margin-bottom: 1.1em;
@@ -203,6 +196,7 @@
         flex-direction: column;
         flex-shrink: 0;
         margin: 1em 0;
+        padding: 0 1em;
       }
       .collapseToggleContainer {
         display: flex;
@@ -250,14 +244,29 @@
       #includedInOverlay {
         width: 65em;
       }
+      #uploadHelpOverlay {
+        width: 50em;
+      }
       @media screen and (min-width: 80em) {
         .commitMessage {
           max-width: var(--commit-message-max-width, 100ch);
         }
       }
+      #metadata {
+        --metadata-horizontal-padding: 1em;
+        padding-top: 1em;
+        width: 100%;
+      }
       /* NOTE: If you update this breakpoint, also update the
       BREAKPOINT_RELATED_MED in the JS */
-      @media screen and (max-width: 60em) {
+      @media screen and (max-width: 75em) {
+        .relatedChanges {
+          padding: 0;
+        }
+        #relatedChanges {
+          border-top: 1px solid var(--border-color);
+          padding-top: 1em;
+        }
         #commitAndRelated {
           flex-direction: column;
           flex-wrap: nowrap;
@@ -265,10 +274,11 @@
         #commitMessageEditor {
           min-width: 0;
         }
-        gr-reply-dialog {
-          height: 100vh;
-          min-width: initial;
-          width: 100vw;
+        .commitMessage {
+          margin-right: 0;
+        }
+        .mainChangeInfo {
+          padding-right: 0;
         }
       }
       /* NOTE: If you update this breakpoint, also update the
@@ -308,16 +318,24 @@
           flex-direction: column;
           flex-wrap: nowrap;
         }
+        .commitContainer {
+          margin: 0;
+          padding: 1em;
+        }
         .relatedChanges,
         .changeMetadata {
           font-size: var(--font-size-normal);
         }
         .changeMetadata {
+          border-bottom: 1px solid var(--border-color);
           border-right: none;
-          margin-bottom: 1em;
           margin-top: .25em;
           max-width: none;
         }
+        #metadata,
+        .mainChangeInfo {
+          padding: 0;
+        }
         .commitActions {
           display: block;
           margin-top: 1em;
@@ -325,13 +343,18 @@
         }
         .commitMessage {
           flex: initial;
-          margin-right: 0;
+          margin: 0;
         }
         /* Change actions are the only thing thant need to remain visible due
         to the fact that they may have the currently visible overlay open. */
         #mainContent.overlayOpen .hideOnMobileOverlay {
           display: none;
         }
+        gr-reply-dialog {
+          height: 100vh;
+          min-width: initial;
+          width: 100vw;
+        }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
@@ -343,7 +366,9 @@
         <div class="headerTitle">
           <gr-change-star
               id="changeStar"
-              change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+              change="{{_change}}"
+              on-toggle-star="_handleToggleStar"
+              hidden$="[[!_loggedIn]]"></gr-change-star>
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
@@ -396,10 +421,10 @@
           <gr-change-metadata
               id="metadata"
               change="{{_change}}"
+              account="[[_account]]"
               revision="[[_selectedRevision]]"
               commit-info="[[_commitInfo]]"
               server-config="[[_serverConfig]]"
-              mutable="[[_loggedIn]]"
               parent-is-current="[[_parentIsCurrent]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
@@ -409,17 +434,16 @@
           <div id="change_plugins"></div>
         </div>
         <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <hr class="mobile">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
-              <div class="replyContainer">
-                  <gr-button
-                      id="replyBtn"
-                      class="reply"
-                      hidden$="[[!_loggedIn]]"
-                      primary
-                      disabled="[[_replyDisabled]]"
-                      on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+              <div>
+                <gr-button
+                    id="replyBtn"
+                    class="reply"
+                    hidden$="[[!_loggedIn]]"
+                    primary
+                    disabled="[[_replyDisabled]]"
+                    on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
               </div>
               <div
                   id="commitMessage"
@@ -507,6 +531,7 @@
             files-expanded="[[_filesExpanded]]"
             on-open-diff-prefs="_handleOpenDiffPrefs"
             on-open-download-dialog="_handleOpenDownloadDialog"
+            on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
             on-open-included-in-dialog="_handleOpenIncludedInDialog"
             on-expand-diffs="_expandAllDiffs"
             on-collapse-diffs="_collapseAllDiffs">
@@ -579,6 +604,11 @@
           config="[[_serverConfig.download]]"
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
+    <gr-overlay id="uploadHelpOverlay" with-backdrop>
+      <gr-upload-help-dialog
+          target-branch="[[_change.branch]]"
+          on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
+    </gr-overlay>
     <gr-overlay id="includedInOverlay" with-backdrop>
       <gr-included-in-dialog
           id="includedInDialog"
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 d2b5ee3..ebd72ad 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
@@ -32,7 +32,7 @@
   // These are the same as the breakpoint set in CSS. Make sure both are changed
   // together.
   const BREAKPOINT_RELATED_SMALL = '50em';
-  const BREAKPOINT_RELATED_MED = '60em';
+  const BREAKPOINT_RELATED_MED = '75em';
 
   // In the event that the related changes medium width calculation is too close
   // to zero, provide some height.
@@ -122,7 +122,7 @@
       _changeComments: Object,
       _canStartReview: {
         type: Boolean,
-        computed: '_computeCanStartReview(_loggedIn, _change, _account)',
+        computed: '_computeCanStartReview(_change)',
       },
       _comments: Object,
       /** @type {?} */
@@ -145,7 +145,7 @@
       _hideEditCommitMessage: {
         type: Boolean,
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change)',
+            '_editingCommitMessage, _change, _editMode)',
       },
       _diffAgainst: String,
       /** @type {?string} */
@@ -394,8 +394,9 @@
       return this.changeStatuses(change, options);
     },
 
-    _computeHideEditCommitMessage(loggedIn, editing, change) {
-      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
+    _computeHideEditCommitMessage(loggedIn, editing, change, editMode) {
+      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED ||
+          editMode) {
         return true;
       }
 
@@ -535,6 +536,14 @@
       this.$.downloadOverlay.close();
     },
 
+    _handleOpenUploadHelpDialog(e) {
+      this.$.uploadHelpOverlay.open();
+    },
+
+    _handleCloseUploadHelpDialog(e) {
+      this.$.uploadHelpOverlay.close();
+    },
+
     _handleMessageReply(e) {
       const msg = e.detail.message.message;
       const quoteStr = msg.split('\n').map(
@@ -952,9 +961,10 @@
     },
 
     _determinePageBack() {
-      // Default backPage to '/' if user came to change view page
+      // Default backPage to root if user came to change view page
       // via an email link, etc.
-      Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/');
+      Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
+          Gerrit.Nav.getUrlForRoot());
     },
 
     _handleLabelRemoved(splices, path) {
@@ -1331,9 +1341,9 @@
       });
     },
 
-    _computeCanStartReview(loggedIn, change, account) {
-      return !!(loggedIn && change.work_in_progress &&
-          change.owner._account_id === account._account_id);
+    _computeCanStartReview(change) {
+      return !!(change.actions && change.actions.ready &&
+          change.actions.ready.enabled);
     },
 
     _computeReplyDisabled() { return false; },
@@ -1622,5 +1632,10 @@
     _resetReplyOverlayFocusStops() {
       this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
     },
+
+    _handleToggleStar(e) {
+      this.$.restAPI.saveChangeStarred(e.detail.change._number,
+          e.detail.starred);
+    },
   });
 })();
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 a2a2de7..4e6cb6e 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
@@ -89,12 +89,13 @@
         assert(starStub.called);
       });
 
-      test('U should navigate to / if no backPage set', () => {
+      test('U should navigate to root if no backPage set', () => {
         const relativeNavStub = sandbox.stub(Gerrit.Nav,
             'navigateToRelativeUrl');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly('/'));
+        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+            Gerrit.Nav.getUrlForRoot()));
       });
 
       test('U should navigate to backPage if set', () => {
@@ -840,6 +841,10 @@
       assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
       assert.isTrue(element._computeHideEditCommitMessage(true, false,
           _change));
+      assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+          true));
+      assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+          false));
     });
 
     test('_handleCommitMessageSave trims trailing whitespace', () => {
@@ -1321,7 +1326,7 @@
         sandbox.stub(element, '_getOffsetHeight', () => 50);
         sandbox.stub(element, '_getLineHeight', () => 12);
         sandbox.stub(window, 'matchMedia', () => {
-          if (window.matchMedia.lastCall.args[0] === '(max-width: 60em)') {
+          if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
             return {matches: true};
           } else {
             return {matches: false};
@@ -1406,18 +1411,24 @@
       });
 
       test('canStartReview computation', () => {
-        const account1 = {_account_id: 1};
-        const account2 = {_account_id: 2};
-        const change = {
-          owner: {_account_id: 1},
+        const change1 = {};
+        const change2 = {
+          actions: {
+            ready: {
+              enabled: true,
+            },
+          },
         };
-        assert.isFalse(element._computeCanStartReview(true, change, account1));
-        change.work_in_progress = false;
-        assert.isFalse(element._computeCanStartReview(true, change, account1));
-        change.work_in_progress = true;
-        assert.isTrue(element._computeCanStartReview(true, change, account1));
-        assert.isFalse(element._computeCanStartReview(false, change, account1));
-        assert.isFalse(element._computeCanStartReview(true, change, account2));
+        const change3 = {
+          actions: {
+            ready: {
+              label: 'Ready for Review',
+            },
+          },
+        };
+        assert.isFalse(element._computeCanStartReview(change1));
+        assert.isTrue(element._computeCanStartReview(change2));
+        assert.isFalse(element._computeCanStartReview(change3));
       });
     });
 
@@ -1731,5 +1742,18 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('_handleToggleStar called when star is tapped', () => {
+      element._change = {
+        owner: {_account_id: 1},
+        starred: false,
+      };
+      element._loggedIn = true;
+      const stub = sandbox.stub(element, '_handleToggleStar');
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$.changeStar.$$('button'));
+      assert.isTrue(stub.called);
+    });
   });
 </script>
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 a3d1ffa..b906753 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
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 954507e..9996abc 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -40,6 +40,7 @@
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
     });
 
     teardown(() => { sandbox.restore(); });
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
index 96a5454..b6c8fcc 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -39,7 +39,6 @@
           has-tooltip
           button-title="Copy full SHA to clipboard"
           hide-input
-          hide-label
           text="[[commitInfo.commit]]">
       </gr-copy-clipboard>
     </div>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index c0a09f3..234d903 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -78,7 +78,7 @@
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
       assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), '../../link-url');
+          element.serverConfig), 'link-url');
     });
 
     test('does not relativize web links that begin with scheme', () => {
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 e420312..8803eb3 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
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-abandon-dialog">
@@ -52,7 +52,7 @@
         }
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Abandon"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -66,7 +66,7 @@
             placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-abandon-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
index a5c047c..95d5374 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -50,7 +50,7 @@
       const confirmHandler = sandbox.stub();
       element.addEventListener('confirm', confirmHandler);
       sandbox.stub(element, '_confirm');
-      element.$$('gr-confirm-dialog').fire('confirm');
+      element.$$('gr-dialog').fire('confirm');
       assert.isTrue(confirmHandler.called);
       assert.isTrue(element._confirm.called);
     });
@@ -59,7 +59,7 @@
       const cancelHandler = sandbox.stub();
       element.addEventListener('cancel', cancelHandler);
       sandbox.stub(element, '_handleCancelTap');
-      element.$$('gr-confirm-dialog').fire('cancel');
+      element.$$('gr-dialog').fire('cancel');
       assert.isTrue(cancelHandler.called);
       assert.isTrue(element._handleCancelTap.called);
     });
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 1590fd9..9e0aa8c 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
@@ -19,7 +19,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-confirm-cherrypick-dialog">
@@ -59,7 +59,7 @@
         };
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Cherry Pick"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -85,7 +85,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-cherrypick-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index 350af900..621ef0a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -19,7 +19,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-confirm-move-dialog">
@@ -58,7 +58,7 @@
         color: var(--error-text-color);
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Move Change"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -87,7 +87,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-move-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index ab4271d..912bbfa6a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -51,7 +51,7 @@
         margin: .5em 0;
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         id="confirmDialog"
         confirm-label="Rebase"
         on-confirm="_handleConfirmTap"
@@ -113,7 +113,7 @@
           </gr-autocomplete>
         </div>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-rebase-dialog.js"></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 07d9b83..9e5f1de 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
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-revert-dialog">
@@ -47,7 +47,7 @@
         }
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Revert"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -63,7 +63,7 @@
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-revert-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
new file mode 100644
index 0000000..346bdd0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
@@ -0,0 +1,63 @@
+<!--
+@license
+Copyright (C) 2018 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="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-confirm-submit-dialog">
+  <template>
+    <style include="shared-styles">
+      #dialog {
+        min-width: 40em;
+      }
+      p {
+        margin-bottom: 1em;
+      }
+      @media screen and (max-width: 50em) {
+        #dialog {
+          min-width: inherit;
+          width: 100%;
+        }
+      }
+    </style>
+    <gr-dialog
+        id="dialog"
+        confirm-label="Continue"
+        confirm-on-enter
+        on-cancel="_handleCancelTap"
+        on-confirm="_handleConfirmTap">
+      <div class="header" slot="header">
+        [[action.label]]
+      </div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>
+            Ready to submit &ldquo;<strong>[[change.subject]]</strong>&rdquo;?
+          </p>
+          <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+          <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </gr-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-confirm-submit-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
new file mode 100644
index 0000000..bda79a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-confirm-submit-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      /**
+       * @type {{
+       *    subject: string,
+       *  }}
+       */
+      change: Object,
+
+      /**
+       * @type {{
+       *    label: string,
+       *  }}
+       */
+      action: Object,
+    },
+
+    resetFocus(e) {
+      this.$.dialog.resetFocus();
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
new file mode 100644
index 0000000..86c15f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-submit-dialog</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="../../../test/common-test-setup.html"/>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="gr-confirm-submit-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-submit-dialog></gr-confirm-submit-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list-header tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('display', () => {
+      element.action = {label: 'my-label'};
+      element.change = {subject: 'my-subject'};
+      flushAsynchronousOperations();
+      const header = element.$$('.header');
+      assert.equal(header.textContent.trim(), 'my-label');
+
+      const message = element.$$('.main p');
+      assert.notEqual(message.textContent.length, 0);
+      assert.notEqual(message.textContent.indexOf('my-subject'), -1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 9085971..545d170 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -28,21 +28,32 @@
       :host {
         background-color: var(--dialog-background-color);
         display: block;
-        padding: 1em;
       }
-      header {
+      section {
         display: flex;
+        padding: .5em 1.5em;
       }
-      footer {
+      section:not(:first-of-type) {
+        border-top: 1px solid var(--border-color);
+      }
+      .flexContainer {
         display: flex;
         justify-content: space-between;
         padding-top: .75em;
       }
+      .footer {
+        justify-content: flex-end;
+      }
       .closeButtonContainer {
+        align-items: flex-end;
         display: flex;
         flex: 0;
         justify-content: flex-end;
       }
+      .patchFiles,
+      .archivesContainer {
+        padding-bottom: .5em;
+      }
       .patchFiles {
         margin-right: 2em;
       }
@@ -56,26 +67,26 @@
         margin-right: 0;
       }
       .title {
-        text-align: center;
         flex: 1;
+        font-family: var(--font-family-bold);
+      }
+      .hidden {
+        display: none;
       }
     </style>
-    <header>
+    <section>
       <span class="title">
         Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
       </span>
-      <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-tap="_handleCloseTap">Close</gr-button>
-      </span>
-    </header>
-     <gr-download-commands
+    </section>
+    <section class$="[[_computeShowDownloadCommands(_schemes)]]">
+      <gr-download-commands
           id="downloadCommands"
           commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
           schemes="[[_schemes]]"
           selected-scheme="{{_selectedScheme}}"></gr-download-commands>
-    <footer>
+    </section>
+    <section class="flexContainer">
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
@@ -104,7 +115,14 @@
           </template>
         </div>
       </div>
-    </footer>
+    </section>
+    <section class="footer">
+      <span class="closeButtonContainer">
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
+      </span>
+    </section>
   </template>
   <script src="gr-download-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index fa3e5c9..20449b0 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -172,5 +172,9 @@
         this._selectedScheme = schemes.sort()[0];
       }
     },
+
+    _computeShowDownloadCommands(schemes) {
+      return schemes.length ? '' : 'hidden';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 84fe8a9..19932c5 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -183,5 +183,10 @@
         MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
       });
     });
+
+    test('_computeShowDownloadCommands', () => {
+      assert.equal(element._computeShowDownloadCommands([]), 'hidden');
+      assert.equal(element._computeShowDownloadCommands(['test']), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index 3dc556d..5520ac9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -78,13 +78,13 @@
         align-items: center;
         display: flex;
       }
-      .downloadContainer {
-        margin-right: 16px;
-      }
+      .downloadContainer,
+      .uploadContainer,
       .includedInContainer {
         margin-right: 16px;
       }
-      .includedInContainer.hide {
+      .includedInContainer.hide,
+      .uploadContainer.hide {
         display: none;
       }
       .rightControls {
@@ -211,6 +211,11 @@
               change="[[change]]"></gr-edit-controls>
           <span class="separator"></span>
         </span>
+        <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
+          <gr-button link
+              class="upload"
+              on-tap="_handleUploadTap">Update Change</gr-button>
+        </span>
         <span class="downloadContainer desktop">
           <gr-button link
               class="download"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 7c5cbc9..665472b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -19,10 +19,35 @@
 
   // Maximum length for patch set descriptions.
   const PATCH_DESC_MAX_LENGTH = 500;
+  const MERGED_STATUS = 'MERGED';
 
   Polymer({
     is: 'gr-file-list-header',
 
+    /**
+     * @event expand-diffs
+     */
+
+    /**
+     * @event collapse-diffs
+     */
+
+    /**
+     * @event open-diff-prefs
+     */
+
+    /**
+     * @event open-included-in-dialog
+     */
+
+    /**
+     * @event open-download-dialog
+     */
+
+    /**
+     * @event open-upload-help-dialog
+     */
+
     properties: {
       account: Object,
       allPatchSets: Array,
@@ -189,7 +214,8 @@
 
     _handleDownloadTap(e) {
       e.preventDefault();
-      this.fire('open-download-dialog');
+      this.dispatchEvent(
+          new CustomEvent('open-download-dialog', {bubbles: false}));
     },
 
     _computeEditModeClass(editMode) {
@@ -209,7 +235,23 @@
     },
 
     _hideIncludedIn(change) {
-      return change && change.status === 'MERGED' ? '' : 'hide';
+      return change && change.status === MERGED_STATUS ? '' : 'hide';
+    },
+
+    _handleUploadTap(e) {
+      e.preventDefault();
+      this.dispatchEvent(
+          new CustomEvent('open-upload-help-dialog', {bubbles: false}));
+    },
+
+    _computeUploadHelpContainerClass(change, account) {
+      const changeIsMerged = change && change.status === MERGED_STATUS;
+      const ownerId = change && change.owner && change.owner._account_id ?
+          change.owner._account_id : null;
+      const userId = account && account._account_id;
+      const userIsOwner = ownerId && userId && ownerId === userId;
+      const hideContainer = !userIsOwner || changeIsMerged;
+      return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index ba186b2..e2685e1 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -298,6 +298,17 @@
         flushAsynchronousOperations();
         assert.isFalse(isVisible(element.$.editControls.parentElement));
       });
+
+      test('_computeUploadHelpContainerClass', () => {
+        // Only show the upload helper button when an unmerged change is viewed
+        // by its owner.
+        const accountA = {_account_id: 1};
+        const accountB = {_account_id: 2};
+        assert.notInclude(element._computeUploadHelpContainerClass(
+            {owner: accountA}, accountA), 'hide');
+        assert.include(element._computeUploadHelpContainerClass(
+            {owner: accountA}, accountB), 'hide');
+      });
     });
   });
 </script>
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 3f26628..fa984c9 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
@@ -23,7 +23,7 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-diff/gr-diff.html">
+<link rel="import" href="../../diff/gr-diff-host/gr-diff-host.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../edit/gr-edit-file-controls/gr-edit-file-controls.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -150,10 +150,10 @@
         min-width: 3.5em;
       }
       .added {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
       }
       .removed {
-        color: #D32F2F;
+        color: var(--vote-text-color-disliked);
         text-align: left;
       }
       .drafts {
@@ -178,10 +178,8 @@
         display: none;
       }
       label.show-hide {
-        color: var(--link-color);
         cursor: pointer;
         display: block;
-        font-size: var(--font-size-small);
         min-width: 2em;
       }
       gr-diff {
@@ -289,6 +287,7 @@
               data-path$="[[file.__path]]" tabindex="-1">
               <div class$="[[_computeClass('status', file.__path)]]"
                   tabindex="0"
+                  title$="[[_computeFileStatusLabel(file.status)]]"
                   aria-label$="[[_computeFileStatusLabel(file.status)]]">
               [[_computeFileStatus(file.status)]]
             </div>
@@ -312,16 +311,16 @@
             </span>
             <div class="comments desktop">
               <span class="drafts">
-                [[_computeDraftsString(changeComments, patchRange.patchNum, file.__path)]]
+                [[_computeDraftsString(changeComments, patchRange, file.__path)]]
               </span>
-              [[_computeCommentsString(changeComments, patchRange.patchNum, file.__path)]]
+              [[_computeCommentsString(changeComments, patchRange, file.__path)]]
             </div>
             <div class="comments mobile">
               <span class="drafts">
-                [[_computeDraftsStringMobile(changeComments, patchRange.patchNum,
+                [[_computeDraftsStringMobile(changeComments, patchRange,
                     file.__path)]]
               </span>
-              [[_computeCommentsStringMobile(changeComments, patchRange.patchNum,
+              [[_computeCommentsStringMobile(changeComments, patchRange,
                   file.__path)]]
             </div>
             <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
@@ -390,8 +389,9 @@
           </div>
           <template is="dom-if"
               if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
-            <gr-diff
+            <gr-diff-host
                 no-auto-render
+                show-load-failure
                 display-line="[[_displayLine]]"
                 inline-index=[[index]]
                 hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
@@ -403,7 +403,7 @@
                 project-config="[[projectConfig]]"
                 on-line-selected="_onLineSelected"
                 no-render-on-prefs-change
-                view-mode="[[diffViewMode]]"></gr-diff>
+                view-mode="[[diffViewMode]]"></gr-diff-host>
           </template>
         </div>
       </template>
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 c5ba182..f54e058 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,6 +35,7 @@
     A: 'Added',
     C: 'Copied',
     D: 'Deleted',
+    M: 'Modified',
     R: 'Renamed',
     W: 'Rewritten',
     U: 'Unchanged',
@@ -282,7 +283,7 @@
     },
 
     get diffs() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff');
+      return Polymer.dom(this.root).querySelectorAll('gr-diff-host');
     },
 
     openDiffPrefs() {
@@ -381,14 +382,17 @@
      * Computes a string with the number of comments and unresolved comments.
      *
      * @param {!Object} changeComments
-     * @param {number} patchNum
+     * @param {!Object} patchRange
      * @param {string} path
      * @return {string}
      */
-    _computeCommentsString(changeComments, patchNum, path) {
-      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
-          path);
-      const commentCount = changeComments.computeCommentCount(patchNum, path);
+    _computeCommentsString(changeComments, patchRange, path) {
+      const unresolvedCount =
+          changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) +
+          changeComments.computeUnresolvedNum(patchRange.patchNum, path);
+      const commentCount =
+          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
+          changeComments.computeCommentCount(patchRange.patchNum, path);
       const commentString = GrCountStringFormatter.computePluralString(
           commentCount, 'comment');
       const unresolvedString = GrCountStringFormatter.computeString(
@@ -405,12 +409,14 @@
      * Computes a string with the number of drafts.
      *
      * @param {!Object} changeComments
-     * @param {number} patchNum
+     * @param {!Object} patchRange
      * @param {string} path
      * @return {string}
      */
-    _computeDraftsString(changeComments, patchNum, path) {
-      const draftCount = changeComments.computeDraftCount(patchNum, path);
+    _computeDraftsString(changeComments, patchRange, path) {
+      const draftCount =
+          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
+          changeComments.computeDraftCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computePluralString(draftCount, 'draft');
     },
 
@@ -418,12 +424,14 @@
      * Computes a shortened string with the number of drafts.
      *
      * @param {!Object} changeComments
-     * @param {number} patchNum
+     * @param {!Object} patchRange
      * @param {string} path
      * @return {string}
      */
-    _computeDraftsStringMobile(changeComments, patchNum, path) {
-      const draftCount = changeComments.computeDraftCount(patchNum, path);
+    _computeDraftsStringMobile(changeComments, patchRange, path) {
+      const draftCount =
+          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
+          changeComments.computeDraftCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computeShortString(draftCount, 'd');
     },
 
@@ -431,12 +439,14 @@
      * Computes a shortened string with the number of comments.
      *
      * @param {!Object} changeComments
-     * @param {number} patchNum
+     * @param {!Object} patchRange
      * @param {string} path
      * @return {string}
      */
-    _computeCommentsStringMobile(changeComments, patchNum, path) {
-      const commentCount = changeComments.computeCommentCount(patchNum, path);
+    _computeCommentsStringMobile(changeComments, patchRange, path) {
+      const commentCount =
+          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
+          changeComments.computeCommentCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computeShortString(commentCount, 'c');
     },
 
@@ -519,6 +529,9 @@
       // link, defer to the native behavior.
       if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
 
+      // Disregard the event if the click target is in the edit controls.
+      if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
+
       e.preventDefault();
       this._togglePathExpanded(path);
     },
@@ -830,10 +843,9 @@
     },
 
     _updateDiffCursor() {
-      const diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
       // Overwrite the cursor's list of diffs:
       this.$.diffCursor.splice(
-          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
     },
 
     _filesChanged() {
@@ -883,6 +895,12 @@
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
+    /**
+     * Get a descriptive label for use in the status indicator's tooltip and
+     * ARIA label.
+     * @param {string} status
+     * @return {string}
+     */
     _computeFileStatusLabel(status) {
       const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
@@ -957,7 +975,7 @@
      * for each path in order, awaiting the previous render to complete before
      * continung.
      * @param  {!Array<string>} paths
-     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+     * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
      * @param  {number} initialCount The total number of paths in the pass. This
      *   is used to generate log messages.
      * @return {!Promise}
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 277d005..88b5f66 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
@@ -69,7 +69,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
@@ -391,68 +391,147 @@
         ],
       };
 
+      const parentTo1 = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
+
+      const parentTo2 = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+
+      const _1To2 = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
+
       assert.equal(
-          element._computeCommentsString(element.changeComments, '1',
+          element._computeCommentsString(element.changeComments, parentTo1,
               '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, '1'
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1
           , '/COMMIT_MSG'), '2c');
       assert.equal(
-          element._computeDraftsString(element.changeComments, '1',
+          element._computeCommentsStringMobile(element.changeComments, _1To2
+          , '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
               'unresolved.file'), '1 draft');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, '1',
+          element._computeDraftsString(element.changeComments, _1To2,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
               'unresolved.file'), '1d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, '1',
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
               'myfile.txt', 'comment'), '1 comment');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, '1',
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1,
               'myfile.txt'), '1c');
       assert.equal(
-          element._computeDraftsString(element.changeComments, '1',
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
               'myfile.txt'), '');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, '1',
+          element._computeDraftsString(element.changeComments, _1To2,
               'myfile.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, '1',
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
               'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, '1',
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1,
               'file_added_in_rev2.txt'), '');
       assert.equal(
-          element._computeDraftsString(element.changeComments, '1',
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
               'file_added_in_rev2.txt'), '');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, '1',
+          element._computeDraftsString(element.changeComments, parentTo1,
               'file_added_in_rev2.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, '2',
+          element._computeDraftsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
               '/COMMIT_MSG', 'comment'), '1 comment');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, '2',
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo2,
               '/COMMIT_MSG'), '1c');
       assert.equal(
-          element._computeDraftsString(element.changeComments, '1',
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
               '/COMMIT_MSG'), '2 drafts');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, '1',
+          element._computeDraftsString(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
               '/COMMIT_MSG'), '2d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, '2',
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
               'myfile.txt', 'comment'), '2 comments');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, '2',
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo2,
               'myfile.txt'), '2c');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, '2',
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo2,
               'myfile.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, '2',
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
               'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(element._computeCommentsString(element.changeComments, '2',
-          'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
     });
 
     suite('keyboard shortcuts', () => {
@@ -718,6 +797,11 @@
       assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
     });
 
+    test('_computeFileStatusLabel', () => {
+      assert.equal(element._computeFileStatusLabel('A'), 'Added');
+      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+    });
+
     test('_handleFileListTap', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
@@ -746,7 +830,7 @@
 
       // Click inside the diff. This should result in no additional calls to
       // _togglePathExpanded or _reviewFile.
-      Polymer.dom(element.root).querySelector('gr-diff').click();
+      Polymer.dom(element.root).querySelector('gr-diff-host').click();
       assert.isTrue(tapSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
@@ -759,6 +843,28 @@
       assert.isTrue(reviewStub.calledOnce);
     });
 
+    test('_handleFileListTap editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.editMode = true;
+      flushAsynchronousOperations();
+      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+
+      // Tap the edit controls. Should be ignored by _handleFileListTap.
+      MockInteractions.tap(element.$$('.editFileControls'));
+      assert.isTrue(tapSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
     test('patch set from revisions', () => {
       const expected = [
         {num: 4, desc: 'test'},
@@ -918,6 +1024,8 @@
           done();
         },
         cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) { callback(new Event(eventName)); },
       }];
       sinon.stub(element, 'diffs', {
         get() { return diffs; },
@@ -1248,7 +1356,6 @@
 
     const setupDiff = function(diff) {
       const mock = document.createElement('mock-diff-response');
-      diff._diff = mock.diffResponse;
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1276,12 +1383,12 @@
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff._renderDiffTable();
+      diff._diff = mock.diffResponse;
     };
 
     const renderAndGetNewDiffs = function(index) {
       const diffs =
-          Polymer.dom(element.root).querySelectorAll('gr-diff');
+          Polymer.dom(element.root).querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
         setupDiff(diffs[i]);
@@ -1304,7 +1411,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 396fed8..0682ab2 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -49,12 +49,12 @@
     },
 
     get selectedItem() {
-      if (!this._ironSelector) { return; }
+      if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selectedItem;
     },
 
     get selectedValue() {
-      if (!this._ironSelector) { return; }
+      if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selected;
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index e9d8ce8..98efa01 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -83,7 +83,7 @@
         color: #1b5e20;
       }
       .submittableCheck {
-        color: #388E3C;
+        color: var(--vote-text-color-recommended);
         display: none;
       }
       .submittableCheck.submittable {
@@ -97,16 +97,9 @@
         .mobile {
           display: block;
         }
-        hr {
-          border: 0;
-          border-top: 1px solid var(--border-color);
-          height: 0;
-          margin-bottom: 1em;
-        }
       }
     </style>
     <div>
-      <hr class="mobile">
       <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
         <h4>Relation chain</h4>
         <template
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index c2ce364..a7abf85 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -204,12 +204,46 @@
 
     _computeChangeContainerClass(currentChange, relatedChange) {
       const classes = ['changeContainer'];
-      if (relatedChange.change_id === currentChange.change_id) {
+      if (this._changesEqual(relatedChange, currentChange)) {
         classes.push('thisChange');
       }
       return classes.join(' ');
     },
 
+    /**
+     * Do the given objects describe the same change? Compares the changes by
+     * their numbers.
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @return {boolean}
+     */
+    _changesEqual(a, b) {
+      const aNum = this._getChangeNumber(a);
+      const bNum = this._getChangeNumber(b);
+      return aNum === bNum;
+    },
+
+    /**
+     * Get the change number from either a ChangeInfo (such as those included in
+     * SubmittedTogetherInfo responses) or get the change number from a
+     * RelatedChangeAndCommitInfo (such as those included in a
+     * RelatedChangesInfo response).
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     *
+     * @param {!Object} change Either a ChangeInfo or a
+     *     RelatedChangeAndCommitInfo object.
+     * @return {number}
+     */
+    _getChangeNumber(change) {
+      if (change.hasOwnProperty('_change_number')) {
+        return change._change_number;
+      }
+      return change._number;
+    },
+
     _computeLinkClass(change) {
       const statuses = [];
       if (change.status == this.ChangeStatus.ABANDONED) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 3f3a255..ef4af16 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -223,13 +223,35 @@
     });
 
     test('_computeChangeContainerClass', () => {
-      const change1 = {change_id: 123};
-      const change2 = {change_id: 456};
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      const change3 = {change_id: 123, _number: 2};
 
       assert.notEqual(element._computeChangeContainerClass(
           change1, change1).indexOf('thisChange'), -1);
       assert.equal(element._computeChangeContainerClass(
           change1, change2).indexOf('thisChange'), -1);
+      assert.equal(element._computeChangeContainerClass(
+          change1, change3).indexOf('thisChange'), -1);
+    });
+
+    test('_changesEqual', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _number: 1};
+      const change3 = {change_id: 123, _number: 2};
+      const change4 = {change_id: 123, _change_number: 1};
+
+      assert.isTrue(element._changesEqual(change1, change1));
+      assert.isFalse(element._changesEqual(change1, change2));
+      assert.isFalse(element._changesEqual(change1, change3));
+      assert.isTrue(element._changesEqual(change2, change4));
+    });
+
+    test('_getChangeNumber', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      assert.equal(element._getChangeNumber(change1), 0);
+      assert.equal(element._getChangeNumber(change2), 1);
     });
 
     test('event for section loaded fires for each section ', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
new file mode 100644
index 0000000..a9843a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -0,0 +1,76 @@
+<!--
+@license
+Copyright (C) 2018 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="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-upload-help-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--dialog-background-color);
+        display: block;
+      }
+      .main {
+        width: 100%;
+      }
+      ol {
+        margin-left: 1em;
+        list-style: decimal;
+      }
+      p {
+        margin-bottom: .75em;
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Done"
+        cancel-label=""
+        on-confirm="_handleCloseTap">
+      <div class="header" slot="header">How to update this change:</div>
+      <div class="main" slot="main">
+        <ol>
+          <li>
+            <p>
+              Checkout this change locally and make your desired modifications to
+              the files.
+            </p>
+          </li>
+          <li>
+            <p>
+              Update the local commit with your modifications using the following
+              command.
+            </p>
+            <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
+            <p>
+              Leave the "Change-Id:" line of the commit message as is.
+            </p>
+          </li>
+          <li>
+            <p>Push the updated commit to Gerrit.</p>
+            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+          </li>
+          <li>
+            <p>Refresh this page to view the the update.</p>
+          </li>
+        </ol>
+      </div>
+    </gr-dialog>
+  </template>
+  <script src="gr-upload-help-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
new file mode 100644
index 0000000..548116c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright (C) 2018 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';
+
+  const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+  const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+
+  Polymer({
+    is: 'gr-upload-help-dialog',
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      targetBranch: String,
+      _commitCommand: {
+        type: String,
+        value: COMMIT_COMMAND,
+        readOnly: true,
+      },
+      _pushCommand: {
+        type: String,
+        computed: '_computePushCommand(targetBranch)',
+      },
+    },
+
+    _handleCloseTap(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+
+    _computePushCommand(targetBranch) {
+      return PUSH_COMMAND_PREFIX + targetBranch;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
new file mode 100644
index 0000000..60fe3e6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-upload-help-dialog</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-upload-help-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-upload-help-dialog></gr-upload-help-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-upload-help-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('constructs push command from branch', () => {
+      element.targetBranch = 'foo';
+      assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
+
+      element.targetBranch = 'master';
+      assert.equal(element._pushCommand,
+          'git push origin HEAD:refs/for/master');
+    });
+  });
+</script>
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 d1ae719..2c2fb4e 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
@@ -26,7 +26,7 @@
   <template>
     <style include="shared-styles">
       gr-dropdown {
-        padding: .5em;
+        padding: 0 .5em;
         --gr-button: {
           color: var(--header-text-color);
         }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
new file mode 100644
index 0000000..f8bf33c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
@@ -0,0 +1,49 @@
+<!--
+@license
+Copyright (C) 2018 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="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-error-dialog">
+  <template>
+    <style include="shared-styles">
+      .main {
+        max-height: 40em;
+        max-width: 60em;
+        overflow-y: auto;
+        white-space: pre-wrap;
+      }
+      @media screen and (max-width: 50em) {
+        .main {
+          max-height: none;
+          max-width: 50em;
+        }
+      }
+    </style>
+    <gr-dialog
+        id="dialog"
+        cancel-label=""
+        on-confirm="_handleConfirm"
+        confirm-label="Dismiss"
+        confirm-on-enter>
+      <div class="header" slot="header">An error occurred</div>
+      <div class="main" slot="main">[[text]]</div>
+    </gr-dialog>
+  </template>
+  <script src="gr-error-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
new file mode 100644
index 0000000..8d3b58e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-error-dialog',
+
+    /**
+     * Fired when the dismiss button is pressed.
+     *
+     * @event dismiss
+     */
+
+    properties: {
+      text: String,
+    },
+
+    _handleConfirm() {
+      this.dispatchEvent(new CustomEvent('dismiss'));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
new file mode 100644
index 0000000..e2c314b
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-error-dialog</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-error-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-error-dialog></gr-error-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-error-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('dismiss tap fires event', done => {
+      element.addEventListener('dismiss', () => { done(); });
+      MockInteractions.tap(element.$.dialog.$.confirm);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 95c5403..3ec4bb5 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -17,11 +17,20 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
 <link rel="import" href="../../shared/gr-alert/gr-alert.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-error-manager">
   <template>
+    <gr-overlay with-backdrop id="errorOverlay">
+      <gr-error-dialog
+          id="errorDialog"
+          on-dismiss="_handleDismissErrorDialog"
+          confirm-label="Dismiss"
+          confirm-on-enter></gr-error-dialog>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-error-manager.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 758148e..e3596e7 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -62,6 +62,7 @@
       this.listen(document, 'network-error', '_handleNetworkError');
       this.listen(document, 'auth-error', '_handleAuthError');
       this.listen(document, 'show-alert', '_handleShowAlert');
+      this.listen(document, 'show-error', '_handleShowErrorDialog');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
       this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
@@ -73,6 +74,7 @@
       this.unlisten(document, 'auth-error', '_handleAuthError');
       this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+      this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     },
 
     _shouldSuppressError(msg) {
@@ -101,7 +103,7 @@
           // This indicates the auth token is no longer valid.
           this._handleAuthError();
         } else if (!this._shouldSuppressError(text)) {
-          this._showAlert('Server error: ' + text);
+          this._showErrorDialog('Server error: ' + text);
         }
         console.error(text);
       });
@@ -257,5 +259,18 @@
     _handleWindowFocus() {
       this.flushDebouncer('checkLoggedIn');
     },
+
+    _handleShowErrorDialog(e) {
+      this._showErrorDialog(e.detail.message);
+    },
+
+    _handleDismissErrorDialog() {
+      this.$.errorOverlay.close();
+    },
+
+    _showErrorDialog(message) {
+      this.$.errorDialog.text = message;
+      this.$.errorOverlay.open();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index c4ba8d2..e28b979 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -87,7 +87,7 @@
     });
 
     test('show normal server error', done => {
-      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
       const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
@@ -96,8 +96,8 @@
         element.$.restAPI.getLoggedIn.lastCall.returnValue,
         textSpy.lastCall.returnValue,
       ]).then(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
         done();
       });
@@ -279,5 +279,21 @@
       element._showAlert();
       assert.isTrue(hideStub.calledOnce);
     });
+
+    test('show-error', () => {
+      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
+      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
+      const message = 'test message';
+      element.fire('show-error', {message});
+      flushAsynchronousOperations();
+
+      assert.isTrue(openStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.fire('dismiss');
+      flushAsynchronousOperations();
+
+      assert.isTrue(closeStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index e4cb243..9db339b8 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -221,6 +221,10 @@
             <td>Show selected change</td>
           </tr>
           <tr>
+            <td><span class="key">r</span></td>
+            <td>Mark (or unmark) change as reviewed</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">r</span>
@@ -416,6 +420,13 @@
           <tr>
             <td>
               <span class="key modifier">Shift</span>
+              <span class="key">x</span>
+            </td>
+            <td>Expand all diff context</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
               <span class="key">n</span>
             </td>
             <td>Show next comment thread</td>
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 04e3794..89b3548 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
@@ -53,6 +53,7 @@
         content: "";
         display: inline-block;
         height: var(--header-icon-size);
+        margin-right: calc(var(--header-icon-size) / 4);
         vertical-align: text-bottom;
         width: var(--header-icon-size);
       }
@@ -136,7 +137,7 @@
       }
       .loginButton {
         color: var(--header-text-color);
-        padding: 1em;
+        padding: .5em 1em;
       }
       .dropdown-trigger {
         text-decoration: none;
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index e217b4b..c5e32cc 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -70,7 +70,15 @@
     //    - `repoName`, required, String: the name of the repo
     //    - `detail`, optional, String: the name of the repo detail view.
     //      Takes any value from Gerrit.Nav.RepoDetailView.
-
+    //
+    //  - Gerrit.Nav.View.DASHBOARD
+    //    - `repo`, optional, String.
+    //    - `sections`, optional, Array of objects with `title` and `query`
+    //      strings.
+    //    - `user`, optional, String.
+    //
+    //  - Gerrit.Nav.View.ROOT:
+    //    - no possible parameters.
 
     window.Gerrit = window.Gerrit || {};
 
@@ -96,6 +104,7 @@
         GROUP: 'group',
         PLUGIN_SCREEN: 'plugin-screen',
         REPO: 'repo',
+        ROOT: 'root',
         SEARCH: 'search',
         SETTINGS: 'settings',
       },
@@ -128,6 +137,9 @@
       /** @type {Function} */
       _generateWeblinks: uninitialized,
 
+      /** @type {Function} */
+      mapCommentlinks: uninitialized,
+
       /**
        * @param {number=} patchNum
        * @param {number|string=} basePatchNum
@@ -140,20 +152,38 @@
 
       /**
        * Setup router implementation.
-       * @param {Function} navigate
-       * @param {Function} generateUrl
-       * @param {Function} generateWeblinks
+       * @param {function(!string)} navigate the router-abstracted equivalent of
+       *     `window.location.href = ...`. Takes a string.
+       * @param {function(!Object): string} generateUrl generates a URL given
+       *     navigation parameters, detailed in the file header.
+       * @param {function(!Object): string} generateWeblinks weblinks generator
+       *     function takes single payload parameter with type property that
+       *  determines which
+       *     part of the UI is the consumer of the weblinks. type property can
+       *     be one of file, change, or patchset.
+       *     - For file type, payload will also contain string properties: repo,
+       *         commit, file.
+       *     - For patchset type, payload will also contain string properties:
+       *         repo, commit.
+       *     - For change type, payload will also contain string properties:
+       *         repo, commit. If server provides weblinks, those will be passed
+       *         as options.weblinks property on the main payload object.
+       * @param {function(!Object): Object} mapCommentlinks provides an escape
+       *     hatch to modify the commentlinks object, e.g. if it contains any
+       *     relative URLs.
        */
-      setup(navigate, generateUrl, generateWeblinks) {
+      setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
         this._navigate = navigate;
         this._generateUrl = generateUrl;
         this._generateWeblinks = generateWeblinks;
+        this.mapCommentlinks = mapCommentlinks;
       },
 
       destroy() {
         this._navigate = uninitialized;
         this._generateUrl = uninitialized;
         this._generateWeblinks = uninitialized;
+        this.mapCommentlinks = uninitialized;
       },
 
       /**
@@ -177,13 +207,15 @@
        * @param {!string} project The name of the project.
        * @param {boolean=} opt_openOnly When true, only search open changes in
        *     the project.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForProjectChanges(project, opt_openOnly) {
+      getUrlForProjectChanges(project, opt_openOnly, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           project,
           statuses: opt_openOnly ? ['open'] : [],
+          host: opt_host,
         });
       },
 
@@ -191,26 +223,30 @@
        * @param {string} branch The name of the branch.
        * @param {string} project The name of the project.
        * @param {string=} opt_status The status to search.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForBranch(branch, project, opt_status) {
+      getUrlForBranch(branch, project, opt_status, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           branch,
           project,
           statuses: opt_status ? [opt_status] : undefined,
+          host: opt_host,
         });
       },
 
       /**
        * @param {string} topic The name of the topic.
+       * @param {string=} opt_host The host in which to search.
        * @return {string}
        */
-      getUrlForTopic(topic) {
+      getUrlForTopic(topic, opt_host) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           topic,
           statuses: ['open', 'merged'],
+          host: opt_host,
         });
       },
 
@@ -267,6 +303,7 @@
           patchNum: opt_patchNum,
           basePatchNum: opt_basePatchNum,
           edit: opt_isEdit,
+          host: change.internalHost || undefined,
         });
       },
 
@@ -405,6 +442,15 @@
       },
 
       /**
+       * @return {string}
+       */
+      getUrlForRoot() {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.ROOT,
+        });
+      },
+
+      /**
        * @param {string} repo The name of the repo.
        * @param {!Array} sections The sections to display in the dashboard
        * @return {string}
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index feff01d..36126e4 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -20,7 +20,8 @@
   // Latency reporting constants.
   const TIMING = {
     TYPE: 'timing-report',
-    CATEGORY: 'UI Latency',
+    CATEGORY_UI_LATENCY: 'UI Latency',
+    CATEGORY_RPC: 'RPC Timing',
     // Reported events - alphabetize below.
     APP_STARTED: 'App Started',
     PAGE_LOADED: 'Page Loaded',
@@ -95,6 +96,9 @@
 
   const INTERACTION_TYPE = 'interaction';
 
+  const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+  const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+
   const pending = [];
 
   const onError = function(oldOnError, msg, url, line, column, error) {
@@ -131,7 +135,9 @@
 
   GrJankDetector.start();
 
-  const GrReporting = Polymer({
+  // The Polymer pass of JSCompiler requires this to be reassignable
+  // eslint-disable-next-line prefer-const
+  let GrReporting = Polymer({
     is: 'gr-reporting',
 
     properties: {
@@ -141,6 +147,11 @@
         type: Object,
         value: STARTUP_TIMERS, // Shared across all instances.
       },
+
+      _timers: {
+        type: Object,
+        value: {timeBetweenDraftActions: null}, // Shared across all instances.
+      },
     },
 
     get performanceTiming() {
@@ -162,7 +173,16 @@
       report.apply(this, args);
     },
 
-    defaultReporter(type, category, eventName, eventValue) {
+    /**
+     * The default reporter reports events immediately.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    defaultReporter(type, category, eventName, eventValue, opt_noLog) {
       const detail = {
         type,
         category,
@@ -170,6 +190,7 @@
         value: eventValue,
       };
       document.dispatchEvent(new CustomEvent(type, {detail}));
+      if (opt_noLog) { return; }
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       } else {
@@ -178,7 +199,17 @@
       }
     },
 
-    cachingReporter(type, category, eventName, eventValue) {
+    /**
+     * The caching reporter will queue reports until plugins have loaded, and
+     * log events immediately if they're reported after plugins have loaded.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    cachingReporter(type, category, eventName, eventValue, opt_noLog) {
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       }
@@ -188,9 +219,9 @@
             this.reporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue);
+        this.reporter(type, category, eventName, eventValue, opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue]);
+        pending.push([type, category, eventName, eventValue, opt_noLog]);
       }
     },
 
@@ -200,8 +231,8 @@
     appStarted(hidden) {
       const startTime =
           new Date().getTime() - this.performanceTiming.navigationStart;
-      this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+          TIMING.APP_STARTED, startTime);
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
@@ -218,8 +249,8 @@
       } else {
         const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
-        this.reporter(
-            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+            TIMING.PAGE_LOADED, loadTime);
       }
     },
 
@@ -307,8 +338,7 @@
     timeEnd(name) {
       if (!this._baselines.hasOwnProperty(name)) { return; }
       const baseTime = this._baselines[name];
-      const time = Math.round(this.now() - baseTime);
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, time);
+      this._reportTiming(name, this.now() - baseTime);
       delete this._baselines[name];
     },
 
@@ -328,13 +358,100 @@
       // Guard against division by zero.
       if (!denominator) { return; }
       const time = Math.round(this.now() - baseTime);
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY, averageName,
-          Math.round(time / denominator));
+      this._reportTiming(averageName, time / denominator);
+    },
+
+    /**
+     * Send a timing report with an arbitrary time value.
+     * @param {string} name Timing name.
+     * @param {number} time The time to report as an integer of milliseconds.
+     */
+    _reportTiming(name, time) {
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name,
+          Math.round(time));
+    },
+
+    /**
+     * Get a timer object to for reporing a user timing. The start time will be
+     * the time that the object has been created, and the end time will be the
+     * time that the "end" method is called on the object.
+     * @param {string} name Timing name.
+     * @returns {!Object} The timer object.
+     */
+    getTimer(name) {
+      let called = false;
+      let start;
+      let max = null;
+
+      const timer = {
+
+        // Clear the timer and reset the start time.
+        reset: () => {
+          called = false;
+          start = this.now();
+          return timer;
+        },
+
+        // Stop the timer and report the intervening time.
+        end: () => {
+          if (called) {
+            throw new Error(`Timer for "${name}" already ended.`);
+          }
+          called = true;
+          const time = this.now() - start;
+
+          // If a maximum is specified and the time exceeds it, do not report.
+          if (max && time > max) { return timer; }
+
+          this._reportTiming(name, time);
+          return timer;
+        },
+
+        // Set a maximum reportable time. If a maximum is set and the timer is
+        // ended after the specified amount of time, the value is not reported.
+        withMaximum(maximum) {
+          max = maximum;
+          return timer;
+        },
+      };
+
+      // The timer is initialized to its creation time.
+      return timer.reset();
+    },
+
+    /**
+     * Log timing information for an RPC.
+     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+     * @param {number} elapsed The time elapsed of the RPC.
+     */
+    reportRpcTiming(anonymizedUrl, elapsed) {
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+          elapsed, true);
     },
 
     reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
+
+    /**
+     * A draft interaction was started. Update the time-betweeen-draft-actions
+     * timer.
+     */
+    recordDraftInteraction() {
+      // If there is no timer defined, then this is the first interaction.
+      // Set up the timer so that it's ready to record the intervening time when
+      // called again.
+      const timer = this._timers.timeBetweenDraftActions;
+      if (!timer) {
+        // Create a timer with a maximum length.
+        this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
+            .withMaximum(DRAFT_ACTION_TIMER_MAX);
+        return;
+      }
+
+      // Mark the time and reinitialize the timer.
+      timer.end().reset();
+    },
   });
 
   window.GrReporting = GrReporting;
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index 3c6d4bf..8b85074 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -178,6 +178,62 @@
       ));
     });
 
+    test('timer object', () => {
+      const nowStub = sandbox.stub(element, 'now').returns(100);
+      const timer = element.getTimer('foo-bar');
+      nowStub.returns(150);
+      timer.end();
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'foo-bar', 50));
+    });
+
+    test('timer object double call', () => {
+      const timer = element.getTimer('foo-bar');
+      timer.end();
+      assert.isTrue(element.reporter.calledOnce);
+      assert.throws(() => {
+        timer.end();
+        done();
+      }, 'Timer for "foo-bar" already ended.');
+    });
+
+    test('timer object maximum', () => {
+      const nowStub = sandbox.stub(element, 'now').returns(100);
+      const timer = element.getTimer('foo-bar').withMaximum(100);
+      nowStub.returns(150);
+      timer.end();
+      assert.isTrue(element.reporter.calledOnce);
+
+      timer.reset();
+      nowStub.returns(260);
+      timer.end();
+      assert.isTrue(element.reporter.calledOnce);
+    });
+
+    test('recordDraftInteraction', () => {
+      const key = 'TimeBetweenDraftActions';
+      const nowStub = sandbox.stub(element, 'now').returns(100);
+      const timingStub = sandbox.stub(element, '_reportTiming');
+      element.recordDraftInteraction();
+      assert.isFalse(timingStub.called);
+
+      nowStub.returns(200);
+      element.recordDraftInteraction();
+      assert.isTrue(timingStub.calledOnce);
+      assert.equal(timingStub.lastCall.args[0], key);
+      assert.equal(timingStub.lastCall.args[1], 100);
+
+      nowStub.returns(350);
+      element.recordDraftInteraction();
+      assert.isTrue(timingStub.calledTwice);
+      assert.equal(timingStub.lastCall.args[0], key);
+      assert.equal(timingStub.lastCall.args[1], 150);
+
+      nowStub.returns(370 + 2 * 60 * 1000);
+      element.recordDraftInteraction();
+      assert.isFalse(timingStub.calledThrice);
+    });
+
     test('timeEndWithAverage', () => {
       const nowStub = sandbox.stub(element, 'now').returns(0);
       nowStub.returns(1000);
@@ -208,7 +264,7 @@
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42, undefined
         ));
       });
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 7186b52..68ddef6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -18,7 +18,7 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-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="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reporting/gr-reporting.html">
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 6adc286..bdd0942 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -176,6 +176,8 @@
 
   const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
+  const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   const app = document.querySelector('#app');
@@ -248,6 +250,8 @@
         url = this._generateGroupUrl(params);
       } else if (params.view === Views.REPO) {
         url = this._generateRepoUrl(params);
+      } else if (params.view === Views.ROOT) {
+        url = '/';
       } else if (params.view === Views.SETTINGS) {
         url = this._generateSettingsUrl(params);
       } else {
@@ -309,12 +313,7 @@
       if (!weblinks) { return null; }
       const weblink = weblinks.find(this._isDirectCommit);
       if (!weblink) { return null; }
-      const url = weblink.url;
-      if (url.startsWith('https:') || url.startsWith('http:')) {
-        return url;
-      } else {
-        return `../../${url}`;
-      }
+      return weblink.url;
     },
 
     _getChangeWeblinks({repo, commit, options: {weblinks}}) {
@@ -397,7 +396,8 @@
         suffix += ',edit';
       }
       if (params.project) {
-        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        const encodedProject = this.encodeURL(params.project, true);
+        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
@@ -408,20 +408,19 @@
      * @return {string}
      */
     _generateDashboardUrl(params) {
+      const repoName = params.repo || params.project || null;
       if (params.sections) {
         // Custom dashboard.
-        const queryParams = params.sections.map(section => {
-          return encodeURIComponent(section.name) + '=' +
-              encodeURIComponent(section.query);
-        });
+        const queryParams = this._sectionsToEncodedParams(params.sections,
+            repoName);
         if (params.title) {
           queryParams.push('title=' + encodeURIComponent(params.title));
         }
         const user = params.user ? params.user : '';
         return `/dashboard/${user}?${queryParams.join('&')}`;
-      } else if (params.project) {
+      } else if (repoName) {
         // Project dashboard.
-        return `/p/${params.project}/+/dashboard/${params.dashboard}`;
+        return `/p/${repoName}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
@@ -429,6 +428,23 @@
     },
 
     /**
+     * @param {!Array<!{name: string, query: string}>} sections
+     * @param {string=} opt_repoName
+     * @return {!Array<string>}
+     */
+    _sectionsToEncodedParams(sections, opt_repoName) {
+      return sections.map(section => {
+        // If there is a repo name provided, make sure to substitute it into the
+        // ${repo} (or legacy ${project}) query tokens.
+        const query = opt_repoName ?
+            section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+            section.query;
+        return encodeURIComponent(section.name) + '=' +
+            encodeURIComponent(query);
+      });
+    },
+
+    /**
      * @param {!Object} params
      * @return {string}
      */
@@ -447,7 +463,8 @@
       }
 
       if (params.project) {
-        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        const encodedProject = this.encodeURL(params.project, true);
+        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
@@ -663,7 +680,8 @@
       Gerrit.Nav.setup(
           url => { page.show(url); },
           this._generateUrl.bind(this),
-          params => this._generateWeblinks(params)
+          params => this._generateWeblinks(params),
+          x => x
       );
 
       page.exit('*', (ctx, next) => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index b68a5e9..53a7c07 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -283,6 +283,16 @@
             '/c/test/+/1234/5..10?revert&foo=bar');
       });
 
+      test('change with repo name encoding', () => {
+        const params = {
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: '1234',
+          project: 'x+/y+/z+/w',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/x%252B/y%252B/z%252B/w/+/1234');
+      });
+
       test('diff', () => {
         const params = {
           view: Gerrit.Nav.View.DIFF,
@@ -317,6 +327,18 @@
             '/c/test/+/42/2/file.cpp#b123');
       });
 
+      test('diff with repo name encoding', () => {
+        const params = {
+          view: Gerrit.Nav.View.DIFF,
+          changeNum: '42',
+          path: 'x+y/path.cpp',
+          patchNum: 12,
+          project: 'x+/y',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+      });
+
       test('edit', () => {
         const params = {
           view: Gerrit.Nav.View.EDIT,
@@ -375,6 +397,21 @@
               '/dashboard/?section%201=query%201&section%202=query%202');
         });
 
+        test('custom repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            sections: [
+              {name: 'section 1', query: 'query 1 ${project}'},
+              {name: 'section 2', query: 'query 2 ${repo}'},
+            ],
+            repo: 'repo-name',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/?section%201=query%201%20repo-name&' +
+              'section%202=query%202%20repo-name');
+        });
+
         test('custom user dashboard, with title', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
@@ -387,7 +424,18 @@
               '/dashboard/user?name=query&title=custom%20dashboard');
         });
 
-        test('project dashboard', () => {
+        test('repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            repo: 'gerrit/repo',
+            dashboard: 'default:main',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/p/gerrit/repo/+/dashboard/default:main');
+        });
+
+        test('project dashboard (legacy)', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
             project: 'gerrit/project',
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 39b2f7e..3a48213 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
@@ -30,7 +30,7 @@
       gr-autocomplete {
         background-color: var(--view-background-color);
         border: 1px solid var(--border-color);
-        border-radius: 2px 0 0 2px;
+        border-radius: 2px;
         flex: 1;
         font: inherit;
         outline: none;
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
index 22fd2aa..9decfa9 100644
--- a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-delete-comment-dialog">
@@ -52,7 +52,7 @@
         }
       }
     </style>
-    <gr-confirm-dialog
+    <gr-dialog
         confirm-label="Delete"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
@@ -66,7 +66,7 @@
             placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
-    </gr-confirm-dialog>
+    </gr-dialog>
   </template>
   <script src="gr-confirm-delete-comment-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index 02ad67b..b2fc64c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -20,9 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, comments, prefs, projectName, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, prefs, projectName, outputEl);
-    console.log('binary village');
+  function GrDiffBuilderBinary(diff, comments, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, comments, null, prefs, outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index fdf18c1..88ff79b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -22,10 +22,10 @@
 
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(
-      diff, comments, prefs, projectName, outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(
-        this, diff, comments, prefs, projectName, outputEl, []);
+  function GrDiffBuilderImage(diff, comments, createThreadGroupFn, prefs,
+      outputEl, baseImage, revisionImage) {
+    GrDiffBuilderSideBySide.call(this, diff, comments, createThreadGroupFn,
+        prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
@@ -52,12 +52,12 @@
     // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
     // column limit.
     td.setAttribute('colspan', '4');
-
     const endpoint = this._createElement('gr-endpoint-decorator');
-    endpoint.setAttribute('name', 'image-diff');
-    endpoint.appendChild(
+    const endpointDomApi = Polymer.dom(endpoint);
+    endpointDomApi.setAttribute('name', 'image-diff');
+    endpointDomApi.appendChild(
         this._createEndpointParam('baseImage', this._baseImage));
-    endpoint.appendChild(
+    endpointDomApi.appendChild(
         this._createEndpointParam('revisionImage', this._revisionImage));
     td.appendChild(endpoint);
     tr.appendChild(td);
@@ -75,10 +75,10 @@
   GrDiffBuilderImage.prototype._emitImagePair = function(section) {
     const tr = this._createElement('tr');
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'left lineNum blank'));
     tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'right lineNum blank'));
     tr.appendChild(this._createImageCell(
         this._revisionImage, 'right', section));
 
@@ -126,7 +126,7 @@
       addNamesInLabel = true;
     }
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'left lineNum blank'));
     let td = this._createElement('td', 'left');
     let label = this._createElement('label');
     let nameSpan;
@@ -145,7 +145,7 @@
     td.appendChild(label);
     tr.appendChild(td);
 
-    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createElement('td', 'right lineNum blank'));
     td = this._createElement('td', 'right');
     label = this._createElement('label');
     labelSpan = this._createElement('span', 'label');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 3d6dedd..fafae63 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,10 +20,10 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderSideBySide(diff, comments, createThreadGroupFn, prefs,
+      outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+        outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 65a31a1..9a04b1f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,10 +20,10 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderUnified(diff, comments, createThreadGroupFn, prefs,
+      outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+        outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
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 e8f4b21..cc66e3b 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
@@ -76,6 +76,9 @@
       // syntax highlighting for the entire file.
       const SYNTAX_MAX_LINE_LENGTH = 500;
 
+      // Disable syntax highlighting if the overall diff is too large.
+      const SYNTAX_MAX_DIFF_LENGTH = 20000;
+
       const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
       Polymer({
@@ -116,6 +119,12 @@
            * @type {Defs.LineOfInterest|null}
            */
           lineOfInterest: Object,
+
+          /**
+           * @type {function(number, booleam, !string)}
+           */
+          createCommentFn: Function,
+
           _builder: Object,
           _groups: Array,
           _layers: Array,
@@ -184,7 +193,7 @@
                 this.dispatchEvent(new CustomEvent('render-content',
                     {bubbles: true}));
 
-                if (this._anyLineTooLong()) {
+                if (this._diffTooLargeForSyntax()) {
                   this.$.syntaxLayer.enabled = false;
                 }
 
@@ -250,12 +259,6 @@
           GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThreadGroup(changeNum, patchNum, path,
-            isOnParent, commentSide) {
-          return this._builder.createCommentThreadGroup(changeNum, patchNum,
-              path, isOnParent, commentSide);
-        },
-
         emitGroup(group, sectionEl) {
           this._builder.emitGroup(group, sectionEl);
         },
@@ -303,27 +306,24 @@
           }
 
           let builder = null;
+          const createFn = this.createCommentFn;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, comments, prefs,
-                this.projectName, this.diffElement, this.baseImage,
-                this.revisionImage);
+            builder = new GrDiffBuilderImage(diff, comments, createFn, prefs,
+              this.diffElement, this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
             return new GrDiffBuilderBinary(diff, comments, prefs,
-                this.projectName, this.diffElement);
+                this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderSideBySide(diff, comments, createFn,
+                prefs, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderUnified(diff, comments, createFn, prefs,
+                this.diffElement, this._layers);
           }
           if (!builder) {
             throw Error('Unsupported diff view mode: ' + this.viewMode);
           }
-          if (this.parentIndex) {
-            builder.setParentIndex(this.parentIndex);
-          }
           return builder;
         },
 
@@ -476,10 +476,32 @@
           }, false);
         },
 
+        _diffTooLargeForSyntax() {
+          return this._anyLineTooLong() ||
+              this.getDiffLength() > SYNTAX_MAX_DIFF_LENGTH;
+        },
+
         setBlame(blame) {
           if (!this._builder || !blame) { return; }
           this._builder.setBlame(blame);
         },
+
+        /**
+         * Get the approximate length of the diff as the sum of the maximum
+         * length of the chunks.
+         * @return {number}
+         */
+        getDiffLength() {
+          return this.diff.content.reduce((sum, sec) => {
+            if (sec.hasOwnProperty('ab')) {
+              return sum + sec.ab.length;
+            } else {
+              return sum + Math.max(
+                  sec.hasOwnProperty('a') ? sec.a.length : 0,
+                  sec.hasOwnProperty('b') ? sec.b.length : 0);
+            }
+          }, 0);
+        },
       });
     })();
   </script>
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 997e1ba..60bf5ca 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
@@ -42,15 +42,15 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
+  function GrDiffBuilder(diff, comments, createThreadGroupFn, prefs, outputEl,
+      layers) {
     this._diff = diff;
     this._comments = comments;
+    this._createThreadGroupFn = createThreadGroupFn;
     this._prefs = prefs;
-    this._projectName = projectName;
     this._outputEl = outputEl;
     this.groups = [];
     this._blameInfo = null;
-    this._parentIndex = undefined;
 
     this.layers = layers || [];
 
@@ -62,7 +62,6 @@
       throw Error('Invalid line length from preferences.');
     }
 
-
     for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
@@ -354,29 +353,7 @@
   };
 
   /**
-   * @param {number} changeNum
-   * @param {number|string} patchNum
-   * @param {string} path
-   * @param {boolean} isOnParent
-   * @param {string} commentSide
-   * @return {!Object}
-   */
-  GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
-      patchNum, path, isOnParent, commentSide) {
-    const threadGroupEl =
-        document.createElement('gr-diff-comment-thread-group');
-    threadGroupEl.changeNum = changeNum;
-    threadGroupEl.commentSide = commentSide;
-    threadGroupEl.patchForNewThreads = patchNum;
-    threadGroupEl.path = path;
-    threadGroupEl.isOnParent = isOnParent;
-    threadGroupEl.projectName = this._projectName;
-    threadGroupEl.parentIndex = this._parentIndex;
-    return threadGroupEl;
-  };
-
-  /**
-   * @param {number} line
+   * @param {GrDiffLine} line
    * @param {string=} opt_side
    * @return {!Object}
    */
@@ -400,9 +377,8 @@
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
-    const threadGroupEl = this.createCommentThreadGroup(
-        this._comments.meta.changeNum, patchNum, this._comments.meta.path,
-        isOnParent, opt_side);
+    const threadGroupEl = this._createThreadGroupFn(patchNum, isOnParent,
+        opt_side);
     threadGroupEl.comments = comments;
     if (opt_side) {
       threadGroupEl.setAttribute('data-side', opt_side);
@@ -544,7 +520,7 @@
     return result;
   };
 
-  GrDiffBuilder.prototype._createElement = function(tagName, className) {
+  GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
     const el = document.createElement(tagName);
     // When Shady DOM is being used, these classes are added to account for
     // Polymer's polyfill behavior. In order to guarantee sufficient
@@ -553,8 +529,10 @@
     // automatically) are not being used for performance reasons, this is
     // done manually.
     el.classList.add('style-scope', 'gr-diff');
-    if (className) {
-      el.classList.add(className);
+    if (classStr) {
+      for (const className of classStr.split(' ')) {
+        el.classList.add(className);
+      }
     }
     return el;
   };
@@ -614,10 +592,6 @@
     }
   };
 
-  GrDiffBuilder.prototype.setParentIndex = function(index) {
-    this._parentIndex = index;
-  };
-
   /**
    * Find the blame cell for a given line number.
    * @param {number} lineNum
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 129bff1..4d7b821 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
@@ -59,6 +59,7 @@
   suite('gr-diff-builder tests', () => {
     let element;
     let builder;
+    let createThreadGroupFn;
     let sandbox;
     const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
@@ -74,13 +75,22 @@
         show_tabs: true,
         tab_size: 4,
       };
-      const projectName = 'my-project';
+      createThreadGroupFn = sinon.spy(() => ({
+        setAttribute: sinon.spy(),
+      }));
       builder = new GrDiffBuilder(
-          {content: []}, {left: [], right: []}, prefs, projectName);
+          {content: []}, {left: [], right: []}, createThreadGroupFn, prefs);
     });
 
     teardown(() => { sandbox.restore(); });
 
+    test('_createElement classStr applies all classes', () => {
+      const node = builder._createElement('div', 'test classes');
+      assert.isTrue(node.classList.contains('gr-diff'));
+      assert.isTrue(node.classList.contains('test'));
+      assert.isTrue(node.classList.contains('classes'));
+    });
+
     test('context control buttons', () => {
       const section = {};
       const line = {contextGroup: {lines: []}};
@@ -304,11 +314,8 @@
 
       function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
           comments) {
-        assert.equal(threadGroupEl.changeNum, '42');
-        assert.equal(threadGroupEl.patchForNewThreads, patchNum);
-        assert.equal(threadGroupEl.path, '/path/to/foo');
-        assert.equal(threadGroupEl.isOnParent, isOnParent);
-        assert.deepEqual(threadGroupEl.projectName, 'my-project');
+        assert.equal(createThreadGroupFn.lastCall.args[0], patchNum);
+        assert.equal(createThreadGroupFn.lastCall.args[1], isOnParent);
         assert.deepEqual(threadGroupEl.comments, comments);
       }
 
@@ -316,27 +323,33 @@
       line.beforeNumber = 5;
       line.afterNumber = 5;
       let threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.isTrue(createThreadGroupFn.calledOnce);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      assert.isTrue(createThreadGroupFn.calledTwice);
       checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      assert.isTrue(createThreadGroupFn.calledThrice);
       checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
 
       builder._comments.meta.patchRange.basePatchNum = '1';
 
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 4);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      assert.equal(createThreadGroupFn.callCount, 5);
       checkThreadGroupProps(threadEl, '1', false, [l5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      assert.equal(createThreadGroupFn.callCount, 6);
       checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
       builder._comments.meta.patchRange.basePatchNum = 'PARENT';
@@ -345,12 +358,14 @@
       line.beforeNumber = 5;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 7);
       checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
 
       line = new GrDiffLine(GrDiffLine.Type.ADD);
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 8);
       checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
     });
 
@@ -913,7 +928,7 @@
         outputEl = element.queryEffectiveChildren('#diffTable');
         sandbox.stub(element, '_getDiffBuilder', () => {
           const builder = new GrDiffBuilder(
-              {content}, {left: [], right: []}, prefs, 'my-project', outputEl);
+              {content}, {left: [], right: []}, null, prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
@@ -1037,6 +1052,10 @@
         });
       });
 
+      test('getDiffLength', () => {
+        assert.equal(element.getDiffLength(diff), 52);
+      });
+
       test('getContentByLine', () => {
         let actual;
 
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 0da1f00..c3a1de4 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
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
@@ -59,7 +60,6 @@
       }
       .descriptionText {
         margin-left: .5rem;
-        font-size: var(--font-size-small);
         font-style: italic;
       }
     </style>
@@ -118,6 +118,7 @@
         </div>
       </div>
     </div>
+    <gr-reporting id="reporting"></gr-reporting>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
   </template>
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 136d23f..d5e6855 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
@@ -241,6 +241,7 @@
 
     _createReplyComment(parent, content, opt_isEditing,
         opt_unresolved) {
+      this.$.reporting.recordDraftInteraction();
       const reply = this._newReply(
           this._orderedComments[this._orderedComments.length - 1].id,
           parent.line,
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 6fb27ec..b525a60 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
@@ -247,8 +247,10 @@
       sandbox.restore();
     });
 
-    test('reply', done => {
+    test('reply', () => {
       const commentEl = element.$$('gr-diff-comment');
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
       assert.ok(commentEl);
 
       const replyBtn = element.$.replyBtn;
@@ -261,11 +263,13 @@
       assert.equal(drafts.length, 1);
       assert.notOk(drafts[0].message, 'message should be empty');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
+      assert.isTrue(reportStub.calledOnce);
     });
 
-    test('quote reply', done => {
+    test('quote reply', () => {
       const commentEl = element.$$('gr-diff-comment');
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
       assert.ok(commentEl);
 
       const quoteBtn = element.$.quoteBtn;
@@ -278,10 +282,12 @@
       assert.equal(drafts.length, 1);
       assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
+      assert.isTrue(reportStub.calledOnce);
     });
 
-    test('quote reply multiline', done => {
+    test('quote reply multiline', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
       element.comments = [{
         author: {
           name: 'Mr. Peanutbutter',
@@ -308,10 +314,12 @@
       assert.equal(drafts[0].message,
           '> is this a crossover episode!?\n> It might be!\n\n');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      done();
+      assert.isTrue(reportStub.calledOnce);
     });
 
     test('ack', done => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
       element.changeNum = '42';
       element.patchNum = '1';
 
@@ -328,11 +336,14 @@
         assert.equal(drafts[0].message, 'Ack');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
         assert.equal(drafts[0].unresolved, false);
+        assert.isTrue(reportStub.calledOnce);
         done();
       });
     });
 
     test('done', done => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
       element.changeNum = '42';
       element.patchNum = '1';
       const commentEl = element.$$('gr-diff-comment');
@@ -348,6 +359,7 @@
         assert.equal(drafts[0].message, 'Done');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
         assert.isFalse(drafts[0].unresolved);
+        assert.isTrue(reportStub.calledOnce);
         done();
       });
     });
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 88ec720..649e867 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
@@ -23,7 +23,7 @@
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
@@ -50,20 +50,24 @@
       :host([disabled]) {
         pointer-events: none;
       }
-      :host([disabled]) .body,
+      :host([disabled]) .actions,
+      :host([disabled]) .robotActions,
       :host([disabled]) .date {
         opacity: .5;
       }
+      :host([discarding]) {
+        display: none;
+      }
       .header {
         align-items: baseline;
         cursor: pointer;
         display: flex;
         font-family: 'Open Sans', sans-serif;
-        margin-bottom: 0.7em;
-        padding-bottom: 0;
+        margin: -.7em -.7em 0 -.7em;
+        padding: .7em;
       }
       .container.collapsed .header {
-        margin: 0;
+        margin-bottom: -.7em;
       }
       .headerMiddle {
         color: var(--deemphasized-text-color);
@@ -209,9 +213,8 @@
       }
       .resolve label {
         color: var(--comment-text-color);
-        font-size: var(--font-size-small);
       }
-      gr-confirm-dialog .main {
+      gr-dialog .main {
         display: flex;
         flex-direction: column;
         width: 100%;
@@ -226,27 +229,20 @@
       #deleteBtn.showDeleteButtons {
         display: block;
       }
-      #savingMessage {
-        display: none;
-      }
-      :host([disabled]) #savingMessage {
-        display: inline;
-      }
     </style>
     <div id="container"
         class="container"
         on-mouseenter="_handleMouseEnter"
         on-mouseleave="_handleMouseLeave">
-      <div class="header" id="header" on-click="_handleToggleCollapsed">
+      <div class="header" id="header" on-tap="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
           <span class="draftLabel">DRAFT</span>
           <gr-tooltip-content class="draftTooltip"
               has-tooltip
-              title="This draft is only visible to you. To publish drafts, click the red 'Reply' button at the top of the change or press the 'A' key."
+              title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
               max-width="20em"
               show-icon></gr-tooltip-content>
-          <span id="savingMessage">[[_savingMessage]]</span>
         </div>
         <div class="headerMiddle">
           <span class="collapsedContent">[[comment.message]]</span>
@@ -370,7 +366,7 @@
         </gr-confirm-delete-comment-dialog>
       </gr-overlay>
       <gr-overlay id="confirmDiscardOverlay" with-backdrop>
-        <gr-confirm-dialog
+        <gr-dialog
             id="confirmDiscardDialog"
             confirm-label="Discard"
             confirm-on-enter
@@ -382,7 +378,7 @@
           <div class="main" slot="main">
             Are you sure you want to discard this draft comment?
           </div>
-        </gr-confirm-dialog>
+        </gr-dialog>
       </gr-overlay>
     </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
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 b2b6b73..90d465f 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
@@ -24,8 +24,6 @@
   const DRAFT_SINGULAR = 'draft...';
   const DRAFT_PLURAL = 'drafts...';
   const SAVED_MESSAGE = 'All changes saved';
-  const SAVING_PROGRESS_MESSAGE = 'Saving draft...';
-  const DiSCARDING_PROGRESS_MESSAGE = 'Discarding draft...';
 
   const REPORT_CREATE_DRAFT = 'CreateDraftComment';
   const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
@@ -94,6 +92,11 @@
         value: false,
         observer: '_editingChanged',
       },
+      discarding: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
       hasChildren: Boolean,
       patchNum: String,
       showActions: Boolean,
@@ -127,8 +130,6 @@
         value: {number: 0}, // Intentional to share the object across instances.
       },
 
-      _savingMessage: String,
-
       _enableOverlay: {
         type: Boolean,
         value: false,
@@ -228,11 +229,10 @@
      */
     save(opt_comment) {
       let comment = opt_comment;
-      if (!comment) {
-        comment = this.comment;
-        this.comment.message = this._messageText;
-      }
+      if (!comment) { comment = this.comment; }
 
+      this.set('comment.message', this._messageText);
+      this.editing = false;
       this.disabled = true;
 
       if (!this._messageText) {
@@ -254,7 +254,6 @@
           }
           resComment.__commentSide = this.commentSide;
           this.comment = resComment;
-          this.editing = false;
           this._fireSave();
           return obj;
         });
@@ -413,40 +412,11 @@
       page.show(window.location.pathname + hash, null, false);
     },
 
-    _handleReply(e) {
-      e.preventDefault();
-      this.fire('create-reply-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
-    _handleQuote(e) {
-      e.preventDefault();
-      this.fire('create-reply-comment', this._getEventPayload({quote: true}),
-          {bubbles: false});
-    },
-
-    _handleFix(e) {
-      e.preventDefault();
-      this.fire('create-fix-comment', this._getEventPayload({quote: true}),
-          {bubbles: false});
-    },
-
-    _handleAck(e) {
-      e.preventDefault();
-      this.fire('create-ack-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
-    _handleDone(e) {
-      e.preventDefault();
-      this.fire('create-done-comment', this._getEventPayload(),
-          {bubbles: false});
-    },
-
     _handleEdit(e) {
       e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
+      this.$.reporting.recordDraftInteraction();
     },
 
     _handleSave(e) {
@@ -456,10 +426,9 @@
       if (this.disabled) { return; }
       const timingLabel = this.comment.id ?
           REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
-      this.$.reporting.time(timingLabel);
+      const timer = this.$.reporting.getTimer(timingLabel);
       this.set('comment.__editing', false);
-      return this.save()
-          .then(() => { this.$.reporting.timeEnd(timingLabel); });
+      return this.save().then(() => { timer.end(); });
     },
 
     _handleCancel(e) {
@@ -480,29 +449,40 @@
       this.fire('comment-discard', this._getEventPayload());
     },
 
+    _handleFix() {
+      this.dispatchEvent(new CustomEvent('create-fix-comment', {
+        bubbles: true,
+        detail: this._getEventPayload(),
+      }));
+    },
+
     _handleDiscard(e) {
       e.preventDefault();
+      this.$.reporting.recordDraftInteraction();
 
       if (!this._messageText) {
         this._discardDraft();
         return;
       }
-      this._openOverlay(this.confirmDiscardOverlay);
+
+      this._openOverlay(this.confirmDiscardOverlay).then(() => {
+        this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
+            .resetFocus();
+      });
     },
 
     _handleConfirmDiscard(e) {
       e.preventDefault();
-      this.$.reporting.time(REPORT_DISCARD_DRAFT);
+      const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
       this._closeConfirmDiscardOverlay();
-      return this._discardDraft()
-          .then(() => { this.$.reporting.timeEnd(REPORT_DISCARD_DRAFT); });
+      return this._discardDraft().then(() => { timer.end(); });
     },
 
     _discardDraft() {
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
-      this._savingMessage = DiSCARDING_PROGRESS_MESSAGE;
+      this.discarding = true;
       this.editing = false;
       this.disabled = true;
       this._eraseDraftComment();
@@ -515,7 +495,10 @@
 
       this._xhrPromise = this._deleteDraft(this.comment).then(response => {
         this.disabled = false;
-        if (!response.ok) { return response; }
+        if (!response.ok) {
+          this.discarding = false;
+          return response;
+        }
 
         this._fireDiscard();
       }).catch(err => {
@@ -569,7 +552,6 @@
     },
 
     _saveDraft(draft) {
-      this._savingMessage = SAVING_PROGRESS_MESSAGE;
       this._showStartRequest();
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
           .then(result => {
@@ -632,6 +614,7 @@
     },
 
     _handleToggleResolved() {
+      this.$.reporting.recordDraftInteraction();
       this.resolved = !this.resolved;
       // Modify payload instead of this.comment, as this.comment is passed from
       // the parent by ref.
@@ -654,9 +637,7 @@
 
     _openOverlay(overlay) {
       Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
-      this.async(() => {
-        overlay.open();
-      }, 1);
+      return overlay.open();
     },
 
     _closeOverlay(overlay) {
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 c7ca782..ca85892 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
@@ -281,7 +281,8 @@
     });
 
     suite('draft update reporting', () => {
-      let timeEndStub;
+      let endStub;
+      let getTimerStub;
       let mockEvent;
 
       setup(() => {
@@ -290,22 +291,26 @@
             .returns(Promise.resolve({}));
         sandbox.stub(element, '_discardDraft')
             .returns(Promise.resolve({}));
-        timeEndStub = sandbox.stub(element.$.reporting, 'timeEnd');
+        endStub = sinon.stub();
+        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
+            .returns({end: endStub});
       });
 
       test('create', () => {
         element.comment = {};
         return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(timeEndStub.calledOnce);
-          assert.equal(timeEndStub.lastCall.args[0], 'CreateDraftComment');
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
         });
       });
 
       test('update', () => {
         element.comment = {id: 'abc_123'};
         return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(timeEndStub.calledOnce);
-          assert.equal(timeEndStub.lastCall.args[0], 'UpdateDraftComment');
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
         });
       });
 
@@ -313,11 +318,27 @@
         element.comment = {id: 'abc_123'};
         sandbox.stub(element, '_closeConfirmDiscardOverlay');
         return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(timeEndStub.calledOnce);
-          assert.equal(timeEndStub.lastCall.args[0], 'DiscardDraftComment');
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
         });
       });
     });
+
+    test('edit reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      MockInteractions.tap(element.$$('.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
   });
 
   suite('gr-diff-comment draft tests', () => {
@@ -593,7 +614,8 @@
 
       setup(() => {
         discardStub = sandbox.stub(element, '_discardDraft');
-        overlayStub = sandbox.stub(element, '_openOverlay');
+        overlayStub = sandbox.stub(element, '_openOverlay')
+            .returns(Promise.resolve());
         mockEvent = {preventDefault: sinon.stub()};
       });
 
@@ -661,7 +683,6 @@
             __commentSide: 'right',
             __draft: true,
             __draftID: 'temp_draft_id',
-            __editing: false,
             id: 'baf0414d_40572e03',
             line: 5,
             message: 'saved!',
@@ -784,23 +805,6 @@
       });
     });
 
-    suite('saving progress indicators', () => {
-      setup(() => {
-        sandbox.stub(element, '_deleteDraft').returns(Promise.resolve());
-        element._savingMessage = '';
-      });
-
-      test('saving', () => {
-        element._saveDraft({});
-        assert.equal(element._savingMessage, 'Saving draft...');
-      });
-
-      test('discarding', () => {
-        element._discardDraft();
-        assert.equal(element._savingMessage, 'Discarding draft...');
-      });
-    });
-
     test('cancelling an unsaved draft discards, persists in storage', () => {
       const discardSpy = sandbox.spy(element, '_fireDiscard');
       const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
@@ -837,5 +841,16 @@
       element.save();
       assert.isTrue(discardStub.called);
     });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener('create-fix-comment', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$$('.fix'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 8a89937..9668a54 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -35,6 +35,7 @@
     <mock-diff-response></mock-diff-response>
     <gr-diff></gr-diff>
     <gr-diff-cursor></gr-diff-cursor>
+    <gr-rest-api-interface></gr-rest-api-interface>
   </template>
 </test-fixture>
 
@@ -48,27 +49,18 @@
     setup(done => {
       sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
       const fixtureElems = fixture('basic');
       mockDiffResponse = fixtureElems[0];
       diffElement = fixtureElems[1];
       cursorElement = fixtureElems[2];
+      const restAPI = fixtureElems[3];
 
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
+      diffElement.loggedIn = false;
+      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
       diffElement.comments = {left: [], right: []};
-      diffElement.$.restAPI.getDiffPreferences().then(prefs => {
-        diffElement.prefs = prefs;
-      });
-
-      sandbox.stub(diffElement, '_getDiff', () => {
-        return Promise.resolve(mockDiffResponse.diffResponse);
-      });
-
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
@@ -77,7 +69,10 @@
       };
       diffElement.addEventListener('render', setupDone);
 
-      diffElement.reload();
+      restAPI.getDiffPreferences().then(prefs => {
+        diffElement.prefs = prefs;
+        diffElement.diff = mockDiffResponse.diffResponse;
+      });
     });
 
     teardown(() => sandbox.restore());
@@ -219,7 +214,7 @@
         done();
       }
       diffElement.addEventListener('render', renderHandler);
-      diffElement.reload();
+      diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
     test('initialLineNumber enabled', done => {
@@ -239,7 +234,7 @@
       cursorElement.initialLineNumber = 10;
       cursorElement.side = 'right';
 
-      diffElement.reload();
+      diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
     test('getAddress', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index a45f8ec..cee3cad 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -156,11 +156,10 @@
 
     /**
      * Adjust triple click selection for the whole line.
-     * domRange.endContainer may be one of the following:
-     * 1) 0 offset at right column's line number cell, or
-     * 2) 0 offset at left column's line number at the next line.
-     * Case 1 means left column was triple clicked.
-     * Case 2 means right column or unified view triple clicked.
+     * A triple click always results in:
+     * - start.column == end.column == 0
+     * - end.line == start.line + 1
+     *
      * @param {!Object} range Normalized range, ie column/line numbers
      * @param {!Range} domRange DOM Range object
      * @return {!Object} fixed normalized range
@@ -172,20 +171,13 @@
       }
       const start = range.start;
       const end = range.end;
-      const endsAtOtherSideLineNum =
-          domRange.endOffset === 0 &&
-          domRange.endContainer.nodeName === 'TD' &&
-          (domRange.endContainer.classList.contains('left') ||
-              domRange.endContainer.classList.contains('right'));
-      const endsOnOtherSideStart = endsAtOtherSideLineNum ||
-          end &&
+      const endsAtBeginningOfNextLine = end &&
+          start.column === 0 &&
           end.column === 0 &&
-          end.line === start.line &&
-          end.side != start.side;
+          end.line === start.line + 1;
       const content = domRange.cloneContents().querySelector('.contentText');
       const lineLength = content && this._getLength(content) || 0;
-      if (lineLength && endsOnOtherSideStart || endsAtOtherSideLineNum) {
-        // Selection ends at the beginning of the next line.
+      if (lineLength && endsAtBeginningOfNextLine) {
         // Move the selection to the end of the previous line.
         range.end = {
           node: start.node,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index a82a11e..7b19338 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -576,54 +576,18 @@
         assert.equal(result, 0);
       });
 
-      // TODO (viktard): Selection starts in line number.
-      // TODO (viktard): Empty lines in selection start.
-      // TODO (viktard): Empty lines in selection end.
-      // TODO (viktard): Only empty lines selected.
-      // TODO (viktard): Unified mode.
-
-      suite('triple click', () => {
-        test('_fixTripleClickSelection', () => {
-          const fakeRange = {
-            startContainer: '',
-            startOffset: '',
-            endContainer: '',
-            endOffset: '',
-          };
-          const fixedRange = {};
-          sandbox.stub(GrRangeNormalizer, 'normalize').returns(fakeRange);
-          sandbox.stub(element, '_normalizeSelectionSide');
-          sandbox.stub(element, '_fixTripleClickSelection').returns(fixedRange);
-          assert.strictEqual(element._normalizeRange({}), fixedRange);
-          assert.isTrue(element._fixTripleClickSelection.called);
+      test('_fixTripleClickSelection', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
+        emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 0,
+          endLine: 119,
+          endChar: element._getLength(startContent),
         });
-
-        test('left pane', () => {
-          const startNode = stubContent(138, 'left');
-          const endNode =
-              stubContent(119, 'right').parentElement.previousElementSibling;
-          builder.getLineNumberByChild.withArgs(endNode).returns(119);
-          emulateSelection(startNode, 0, endNode, 0);
-          assert.deepEqual(getActionRange(), {
-            startLine: 138,
-            startChar: 0,
-            endLine: 138,
-            endChar: 63,
-          });
-        });
-
-        test('right pane', () => {
-          const startNode = stubContent(119, 'right');
-          const endNode =
-              stubContent(140, 'left').parentElement.previousElementSibling;
-          emulateSelection(startNode, 0, endNode, 0);
-          assert.deepEqual(getActionRange(), {
-            startLine: 119,
-            startChar: 0,
-            endLine: 119,
-            endChar: 63,
-          });
-        });
+        assert.equal(getActionSide(), 'right');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
new file mode 100644
index 0000000..e3bf866
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -0,0 +1,55 @@
+<!--
+@license
+Copyright (C) 2018 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="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-diff/gr-diff.html">
+
+<dom-module id="gr-diff-host">
+  <template>
+    <gr-diff
+        id="diff"
+        change-num="[[changeNum]]"
+        no-auto-render=[[noAutoRender]]
+        patch-range="[[patchRange]]"
+        path="[[path]]"
+        prefs="[[prefs]]"
+        project-config="[[projectConfig]]"
+        project-name="[[projectName]]"
+        display-line="[[displayLine]]"
+        is-image-diff="[[isImageDiff]]"
+        commit-range="[[commitRange]]"
+        hidden$="[[hidden]]"
+        no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+        comments="[[comments]]"
+        line-wrapping="[[lineWrapping]]"
+        view-mode="[[viewMode]]"
+        line-of-interest="[[lineOfInterest]]"
+        logged-in="[[_loggedIn]]"
+        loading="[[_loading]]"
+        error-message="[[_errorMessage]]"
+        base-image="[[_baseImage]]"
+        revision-image=[[_revisionImage]]
+        blame="[[_blame]]"
+        diff="[[_diff]]"></gr-diff>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting" category="diff"></gr-reporting>
+  </template>
+  <script src="gr-diff-host.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
new file mode 100644
index 0000000..6f61fb9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -0,0 +1,510 @@
+/**
+ * @license
+ * Copyright (C) 2018 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';
+
+  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+
+  const EVENT_AGAINST_PARENT = 'diff-against-parent';
+  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+
+  const DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+
+  /**
+   * @param {Object} diff
+   * @return {boolean}
+   */
+  function isImageDiff(diff) {
+    if (!diff) { return false; }
+
+    const isA = diff.meta_a &&
+        diff.meta_a.content_type.startsWith('image/');
+    const isB = diff.meta_b &&
+        diff.meta_b.content_type.startsWith('image/');
+
+    return !!(diff.binary && (isA || isB));
+  }
+
+  /**
+   * Wrapper around gr-diff.
+   *
+   * Webcomponent fetching diffs and related data from restAPI and passing them
+   * to the presentational gr-diff for rendering.
+   */
+  // TODO(oler): Move all calls to restAPI from gr-diff here.
+  Polymer({
+    is: 'gr-diff-host',
+
+    /**
+     * Fired when the user selects a line.
+     * @event line-selected
+     */
+
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
+    /**
+     * Fired when a comment is saved or discarded
+     *
+     * @event diff-comments-modified
+     */
+
+    properties: {
+      changeNum: String,
+      noAutoRender: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+      },
+      projectConfig: {
+        type: Object,
+      },
+      projectName: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
+      isImageDiff: {
+        type: Boolean,
+        computed: '_computeIsImageDiff(_diff)',
+        notify: true,
+      },
+      commitRange: Object,
+      filesWeblinks: {
+        type: Object,
+        value() { return {}; },
+        notify: true,
+      },
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      noRenderOnPrefsChange: {
+        type: Boolean,
+        value: false,
+      },
+      comments: Object,
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+      },
+      viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+      },
+
+      /**
+       * Special line number which should not be collapsed into a shared region.
+       * @type {{
+       *  number: number,
+       *  leftSide: {boolean}
+       * }|null}
+       */
+      lineOfInterest: Object,
+
+      /**
+       * If the diff fails to load, show the failure message in the diff rather
+       * than bubbling the error up to the whole page. This is useful for when
+       * loading inline diffs because one diff failing need not mark the whole
+       * page with a failure.
+       */
+      showLoadFailure: Boolean,
+
+      isBlameLoaded: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeIsBlameLoaded(_blame)',
+      },
+
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+
+      /** @type {?string} */
+      _errorMessage: {
+        type: String,
+        value: null,
+      },
+
+      /** @type {?Object} */
+      _baseImage: Object,
+      /** @type {?Object} */
+      _revisionImage: Object,
+
+      _diff: Object,
+
+      /** @type {?Object} */
+      _blame: {
+        type: Object,
+        value: null,
+      },
+
+      _loadedWhitespaceLevel: String,
+    },
+
+    listeners: {
+      'draft-interaction': '_handleDraftInteraction',
+    },
+
+    observers: [
+      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
+          ' noRenderOnPrefsChange)',
+    ],
+
+    ready() {
+      if (this._canReload()) {
+        this.reload();
+      }
+    },
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
+    },
+
+    /** @return {!Promise} */
+    reload() {
+      this._loading = true;
+      this._errorMessage = null;
+      const whitespaceLevel = this._getIgnoreWhitespace();
+
+      const diffRequest = this._getDiff()
+          .then(diff => {
+            this._loadedWhitespaceLevel = whitespaceLevel;
+            this._reportDiff(diff);
+            if (this._getIgnoreWhitespace() !== WHITESPACE_IGNORE_NONE) {
+              return this._translateChunksToIgnore(diff);
+            }
+            return diff;
+          })
+          .catch(e => {
+            this._handleGetDiffError(e);
+            return null;
+          });
+
+      const assetRequest = diffRequest.then(diff => {
+        // If the diff is null, then it's failed to load.
+        if (!diff) { return null; }
+
+        return this._loadDiffAssets(diff);
+      });
+
+      return Promise.all([diffRequest, assetRequest])
+          .then(results => {
+            const diff = results[0];
+            if (!diff) {
+              return Promise.resolve();
+            }
+            this.filesWeblinks = this._getFilesWeblinks(diff);
+            return new Promise(resolve => {
+              const callback = () => {
+                resolve();
+                this.removeEventListener('render', callback);
+              };
+              this.addEventListener('render', callback);
+              this._diff = diff;
+            });
+          })
+          .catch(err => {
+            console.warn('Error encountered loading diff:', err);
+          })
+          .then(() => { this._loading = false; });
+    },
+
+    _getFilesWeblinks(diff) {
+      if (!this.commitRange) { return {}; }
+      return {
+        meta_a: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.baseCommit, this.path,
+            {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+        meta_b: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.commit, this.path,
+            {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
+      };
+    },
+
+    /** Cancel any remaining diff builder rendering work. */
+    cancel() {
+      this.$.diff.cancel();
+    },
+
+    /** @return {!Array<!HTMLElement>} */
+    getCursorStops() {
+      return this.$.diff.getCursorStops();
+    },
+
+    /** @return {boolean} */
+    isRangeSelected() {
+      return this.$.diff.isRangeSelected();
+    },
+
+    toggleLeftDiff() {
+      this.$.diff.toggleLeftDiff();
+    },
+
+    /**
+     * Load and display blame information for the base of the diff.
+     * @return {Promise} A promise that resolves when blame finishes rendering.
+     */
+    loadBlame() {
+      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+          this.path, true)
+          .then(blame => {
+            if (!blame.length) {
+              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
+              return Promise.reject(MSG_EMPTY_BLAME);
+            }
+
+            this._blame = blame;
+          });
+    },
+
+    /** Unload blame information for the diff. */
+    clearBlame() {
+      this._blame = null;
+    },
+
+    /** @return {!Array<!HTMLElement>} */
+    getThreadEls() {
+      return this.$.diff.getThreadEls();
+    },
+
+    /** @param {HTMLElement} el */
+    addDraftAtLine(el) {
+      this.$.diff.addDraftAtLine(el);
+    },
+
+    clearDiffContent() {
+      this.$.diff.clearDiffContent();
+    },
+
+    expandAllContext() {
+      this.$.diff.expandAllContext();
+    },
+
+    /** @return {!Promise} */
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    /** @return {boolean}} */
+    _canReload() {
+      return !!this.changeNum && !!this.patchRange && !!this.path &&
+          !this.noAutoRender;
+    },
+
+    /** @return {!Promise<!Object>} */
+    _getDiff() {
+      // Wrap the diff request in a new promise so that the error handler
+      // rejects the promise, allowing the error to be handled in the .catch.
+      return new Promise((resolve, reject) => {
+        this.$.restAPI.getDiff(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path,
+            this._getIgnoreWhitespace(),
+            reject)
+            .then(resolve);
+      });
+    },
+
+    _handleGetDiffError(response) {
+      // Loading the diff may respond with 409 if the file is too large. In this
+      // case, use a toast error..
+      if (response.status === 409) {
+        this.fire('server-error', {response});
+        return;
+      }
+
+      if (this.showLoadFailure) {
+        this._errorMessage = [
+          'Encountered error when loading the diff:',
+          response.status,
+          response.statusText,
+        ].join(' ');
+        return;
+      }
+
+      this.fire('page-error', {response});
+    },
+
+    /**
+     * Report info about the diff response.
+     */
+    _reportDiff(diff) {
+      if (!diff || !diff.content) { return; }
+
+      // Count the delta lines stemming from normal deltas, and from
+      // due_to_rebase deltas.
+      let nonRebaseDelta = 0;
+      let rebaseDelta = 0;
+      diff.content.forEach(chunk => {
+        if (chunk.ab) { return; }
+        const deltaSize = Math.max(
+            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+        if (chunk.due_to_rebase) {
+          rebaseDelta += deltaSize;
+        } else {
+          nonRebaseDelta += deltaSize;
+        }
+      });
+
+      // Find the percent of the delta from due_to_rebase chunks rounded to two
+      // digits. Diffs with no delta are considered 0%.
+      const totalDelta = rebaseDelta + nonRebaseDelta;
+      const percentRebaseDelta = !totalDelta ? 0 :
+          Math.round(100 * rebaseDelta / totalDelta);
+
+      // Report the due_to_rebase percentage in the "diff" category when
+      // applicable.
+      if (this.patchRange.basePatchNum === 'PARENT') {
+        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+      } else if (percentRebaseDelta === 0) {
+        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+      } else {
+        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+            percentRebaseDelta);
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {!Promise}
+     */
+    _loadDiffAssets(diff) {
+      if (isImageDiff(diff)) {
+        return this._getImages(diff).then(images => {
+          this._baseImage = images.baseImage;
+          this._revisionImage = images.revisionImage;
+        });
+      } else {
+        this._baseImage = null;
+        this._revisionImage = null;
+        return Promise.resolve();
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {boolean}
+     */
+    _computeIsImageDiff(diff) {
+      return isImageDiff(diff);
+    },
+
+    /**
+     * @param {Object} blame
+     * @return {boolean}
+     */
+    _computeIsBlameLoaded(blame) {
+      return !!blame;
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {!Promise}
+     */
+    _getImages(diff) {
+      return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
+          this.patchRange);
+    },
+
+    _handleDraftInteraction() {
+      this.$.reporting.recordDraftInteraction();
+    },
+
+    /**
+     * Take a diff that was loaded with a ignore-whitespace other than
+     * IGNORE_NONE, and convert delta chunks labeled as common into shared
+     * chunks.
+     * @param {!Object} diff
+     * @returns {!Object}
+     */
+    _translateChunksToIgnore(diff) {
+      const newDiff = Object.assign({}, diff);
+      const mergedContent = [];
+
+      // Was the last chunk visited a shared chunk?
+      let lastWasShared = false;
+
+      for (const chunk of diff.content) {
+        if (lastWasShared && chunk.common && chunk.b) {
+          // The last chunk was shared and this chunk should be ignored, so
+          // add its revision content to the previous chunk.
+          mergedContent[mergedContent.length - 1].ab.push(...chunk.b);
+        } else if (chunk.common && !chunk.b) {
+          // If the chunk should be ignored, but it doesn't have revision
+          // content, then drop it and continue without updating lastWasShared.
+          continue;
+        } else if (lastWasShared && chunk.ab) {
+          // Both the last chunk and the current chunk are shared. Merge this
+          // chunk's shared content into the previous shared content.
+          mergedContent[mergedContent.length - 1].ab.push(...chunk.ab);
+        } else if (!lastWasShared && chunk.common && chunk.b) {
+          // If the previous chunk was not shared, but this one should be
+          // ignored, then add it as a shared chunk.
+          mergedContent.push({ab: chunk.b});
+        } else {
+          // Otherwise add the chunk as is.
+          mergedContent.push(chunk);
+        }
+
+        lastWasShared = !!mergedContent[mergedContent.length - 1].ab;
+      }
+
+      newDiff.content = mergedContent;
+      return newDiff;
+    },
+
+    _getIgnoreWhitespace() {
+      if (!this.prefs || !this.prefs.ignore_whitespace) {
+        return WHITESPACE_IGNORE_NONE;
+      }
+      return this.prefs.ignore_whitespace;
+    },
+
+    _whitespaceChanged(preferredWhitespaceLevel, loadedWhitespaceLevel,
+        noRenderOnPrefsChange) {
+      if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+          !noRenderOnPrefsChange) {
+        this.reload();
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
new file mode 100644
index 0000000..f83253e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -0,0 +1,863 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-diff</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="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-diff-host.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-host></gr-diff-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-host tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('reload() cancels before network resolves', () => {
+      const cancelStub = sandbox.stub(element.$.diff, 'cancel');
+
+      // Stub the network calls into requests that never resolve.
+      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+
+      element.reload();
+      assert.isTrue(cancelStub.called);
+    });
+
+    suite('not logged in', () => {
+      setup(() => {
+        const getLoggedInPromise = Promise.resolve(false);
+        stub('gr-rest-api-interface', {
+          getLoggedIn() { return getLoggedInPromise; },
+        });
+        element = fixture('basic');
+        return getLoggedInPromise;
+      });
+
+      test('reload() loads files weblinks', () => {
+        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+            .returns({name: 'stubb', url: '#s'});
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
+          content: [],
+        }));
+        element.projectName = 'test-project';
+        element.path = 'test-path';
+        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+        element.patchRange = {};
+        return element.reload().then(() => {
+          assert.isTrue(weblinksStub.calledTwice);
+          assert.isTrue(weblinksStub.firstCall.calledWith({
+            commit: 'test-base',
+            file: 'test-path',
+            options: {
+              weblinks: undefined,
+            },
+            repo: 'test-project',
+            type: Gerrit.Nav.WeblinkType.FILE}));
+          assert.isTrue(weblinksStub.secondCall.calledWith({
+            commit: 'test-commit',
+            file: 'test-path',
+            options: {
+              weblinks: undefined,
+            },
+            repo: 'test-project',
+            type: Gerrit.Nav.WeblinkType.FILE}));
+          assert.deepEqual(element.filesWeblinks, {
+            meta_a: [{name: 'stubb', url: '#s'}],
+            meta_b: [{name: 'stubb', url: '#s'}],
+          });
+        });
+      });
+
+      test('_getDiff handles null diff responses', done => {
+        stub('gr-rest-api-interface', {
+          getDiff() { return Promise.resolve(null); },
+        });
+        element.changeNum = 123;
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        element.path = 'file.txt';
+        element._getDiff().then(done);
+      });
+
+      test('reload resolves on error', () => {
+        const onErrStub = sandbox.stub(element, '_handleGetDiffError');
+        const error = {ok: false, status: 500};
+        sandbox.stub(element.$.restAPI, 'getDiff',
+            (changeNum, basePatchNum, patchNum, path, onErr) => {
+              onErr(error);
+            });
+        return element.reload().then(() => {
+          assert.isTrue(onErrStub.calledOnce);
+        });
+      });
+
+      suite('_handleGetDiffError', () => {
+        let serverErrorStub;
+        let pageErrorStub;
+
+        setup(() => {
+          serverErrorStub = sinon.stub();
+          element.addEventListener('server-error', serverErrorStub);
+          pageErrorStub = sinon.stub();
+          element.addEventListener('page-error', pageErrorStub);
+        });
+
+        test('page error on HTTP-409', () => {
+          element._handleGetDiffError({status: 409});
+          assert.isTrue(serverErrorStub.calledOnce);
+          assert.isFalse(pageErrorStub.called);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('server error on non-HTTP-409', () => {
+          element._handleGetDiffError({status: 500});
+          assert.isFalse(serverErrorStub.called);
+          assert.isTrue(pageErrorStub.calledOnce);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('error message if showLoadFailure', () => {
+          element.showLoadFailure = true;
+          element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+          assert.isFalse(serverErrorStub.called);
+          assert.isFalse(pageErrorStub.called);
+          assert.equal(element._errorMessage,
+              'Encountered error when loading the diff: 500 Failure!');
+        });
+      });
+
+      suite('image diffs', () => {
+        let mockFile1;
+        let mockFile2;
+        setup(() => {
+          mockFile1 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+            type: 'image/bmp',
+          };
+          mockFile2 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+            type: 'image/bmp',
+          };
+          sandbox.stub(element.$.restAPI,
+              'getB64FileContents',
+              (changeId, patchNum, path, opt_parentIndex) => {
+                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
+                    mockFile2);
+              });
+
+          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.comments = {left: [], right: []};
+        });
+
+        test('renders image diffs with same file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diff.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diff.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isNotOk(rightLabelName);
+            assert.isNotOk(leftLabelName);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders image diffs with a different file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot2.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot2.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diff.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diff.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isOk(rightLabelName);
+            assert.isOk(leftLabelName);
+            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders added image', done => {
+          const mockDiff = {
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'ADDED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 0000000..f9c2f2c 100644',
+              '--- /dev/null',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+
+            assert.isNotOk(leftImage);
+            assert.isOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders removed image', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+
+            assert.isOk(leftImage);
+            assert.isNotOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('does not render disallowed image type', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          mockFile1.type = 'image/jpeg-evil';
+
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            assert.isNotOk(leftImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+      });
+    });
+
+    test('delegates cancel()', () => {
+      const stub = sandbox.stub(element.$.diff, 'cancel');
+      element.reload();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates getCursorStops()', () => {
+      const returnValue = [document.createElement('b')];
+      const stub = sandbox.stub(element.$.diff, 'getCursorStops')
+          .returns(returnValue);
+      assert.equal(element.getCursorStops(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates isRangeSelected()', () => {
+      const returnValue = true;
+      const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
+          .returns(returnValue);
+      assert.equal(element.isRangeSelected(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates toggleLeftDiff()', () => {
+      const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+      element.toggleLeftDiff();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    suite('blame', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      test('clearBlame', () => {
+        element._blame = [];
+        const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
+        element.clearBlame();
+        assert.isNull(element._blame);
+        assert.isTrue(setBlameSpy.calledWithExactly(null));
+        assert.equal(element.isBlameLoaded, false);
+      });
+
+      test('loadBlame', () => {
+        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame().then(() => {
+          assert.isTrue(getBlameStub.calledWithExactly(
+              42, 5, 'foo/bar.baz', true));
+          assert.isFalse(showAlertStub.called);
+          assert.equal(element._blame, mockBlame);
+          assert.equal(element.isBlameLoaded, true);
+        });
+      });
+
+      test('loadBlame empty', () => {
+        const mockBlame = [];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame()
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(() => {
+              assert.isTrue(showAlertStub.calledOnce);
+              assert.isNull(element._blame);
+              assert.equal(element.isBlameLoaded, false);
+            });
+      });
+    });
+
+    test('delegates getThreadEls()', () => {
+      const returnValue = [document.createElement('b')];
+      const stub = sandbox.stub(element.$.diff, 'getThreadEls')
+          .returns(returnValue);
+      assert.equal(element.getThreadEls(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates addDraftAtLine(el)', () => {
+      const param0 = document.createElement('b');
+      const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
+      element.addDraftAtLine(param0);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 1);
+      assert.equal(stub.lastCall.args[0], param0);
+    });
+
+    test('delegates clearDiffContent()', () => {
+      const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
+      element.clearDiffContent();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates expandAllContext()', () => {
+      const stub = sandbox.stub(element.$.diff, 'expandAllContext');
+      element.expandAllContext();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('passes in changeNum', () => {
+      const value = '12345';
+      element.changeNum = value;
+      assert.equal(element.$.diff.changeNum, value);
+    });
+
+    test('passes in noAutoRender', () => {
+      const value = true;
+      element.noAutoRender = value;
+      assert.equal(element.$.diff.noAutoRender, value);
+    });
+
+    test('passes in patchRange', () => {
+      const value = {patchNum: 'foo', basePatchNum: 'bar'};
+      element.patchRange = value;
+      assert.equal(element.$.diff.patchRange, value);
+    });
+
+    test('passes in path', () => {
+      const value = 'some/file/path';
+      element.path = value;
+      assert.equal(element.$.diff.path, value);
+    });
+
+    test('passes in prefs', () => {
+      const value = {};
+      element.prefs = value;
+      assert.equal(element.$.diff.prefs, value);
+    });
+
+    test('passes in projectConfig', () => {
+      const value = {};
+      element.projectConfig = value;
+      assert.equal(element.$.diff.projectConfig, value);
+    });
+
+    test('passes in changeNum', () => {
+      const value = '12345';
+      element.changeNum = value;
+      assert.equal(element.$.diff.changeNum, value);
+    });
+
+    test('passes in projectName', () => {
+      const value = 'Gerrit';
+      element.projectName = value;
+      assert.equal(element.$.diff.projectName, value);
+    });
+
+    test('passes in displayLine', () => {
+      const value = true;
+      element.displayLine = value;
+      assert.equal(element.$.diff.displayLine, value);
+    });
+
+    test('passes in commitRange', () => {
+      const value = {};
+      element.commitRange = value;
+      assert.equal(element.$.diff.commitRange, value);
+    });
+
+    test('passes in hidden', () => {
+      const value = true;
+      element.hidden = value;
+      assert.equal(element.$.diff.hidden, value);
+      assert.isNotNull(element.getAttribute('hidden'));
+    });
+
+    test('passes in noRenderOnPrefsChange', () => {
+      const value = true;
+      element.noRenderOnPrefsChange = value;
+      assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+    });
+
+    test('passes in comments', () => {
+      const value = {left: [], right: []};
+      element.comments = value;
+      assert.equal(element.$.diff.comments, value);
+    });
+
+    test('passes in lineWrapping', () => {
+      const value = true;
+      element.lineWrapping = value;
+      assert.equal(element.$.diff.lineWrapping, value);
+    });
+
+    test('passes in viewMode', () => {
+      const value = 'SIDE_BY_SIDE';
+      element.viewMode = value;
+      assert.equal(element.$.diff.viewMode, value);
+    });
+
+    test('passes in lineOfInterest', () => {
+      const value = {number: 123, leftSide: true};
+      element.lineOfInterest = value;
+      assert.equal(element.$.diff.lineOfInterest, value);
+    });
+
+    suite('_reportDiff', () => {
+      let reportStub;
+
+      setup(() => {
+        element = fixture('basic');
+        element.patchRange = {basePatchNum: 1};
+        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
+      });
+
+      test('null and content-less', () => {
+        element._reportDiff(null);
+        assert.isFalse(reportStub.called);
+
+        element._reportDiff({});
+        assert.isFalse(reportStub.called);
+      });
+
+      test('diff w/ no delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {ab: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+
+      test('diff w/ no rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+
+      test('diff w/ some rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
+        assert.strictEqual(reportStub.lastCall.args[1], 50);
+      });
+
+      test('diff w/ all rebase delta', () => {
+        const diff = {content: [{
+          a: ['foo', 'bar'],
+          b: ['baz', 'foo'],
+          due_to_rebase: true,
+        }]};
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
+        assert.strictEqual(reportStub.lastCall.args[1], 100);
+      });
+
+      test('diff against parent event', () => {
+        element.patchRange.basePatchNum = 'PARENT';
+        const diff = {content: [{
+          a: ['foo', 'bar'],
+          b: ['baz', 'foo'],
+        }]};
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+    });
+
+    suite('_translateChunksToIgnore', () => {
+      let content;
+
+      setup(() => {
+        content = [
+          {ab: ['one', 'two']},
+          {a: ['three'], b: ['different three']},
+          {b: ['four']},
+          {ab: ['five', 'six']},
+          {a: ['seven']},
+          {ab: ['eight', 'nine']},
+        ];
+      });
+
+      test('does nothing to unmarked diff', () => {
+        assert.deepEqual(element._translateChunksToIgnore({content}),
+            {content});
+      });
+
+      test('merges marked delta chunk', () => {
+        content[1].common = true;
+        assert.deepEqual(element._translateChunksToIgnore({content}), {
+          content: [
+            {ab: ['one', 'two', 'different three']},
+            {b: ['four']},
+            {ab: ['five', 'six']},
+            {a: ['seven']},
+            {ab: ['eight', 'nine']},
+          ],
+        });
+      });
+
+      test('merges marked addition chunk', () => {
+        content[2].common = true;
+        assert.deepEqual(element._translateChunksToIgnore({content}), {
+          content: [
+            {ab: ['one', 'two']},
+            {a: ['three'], b: ['different three']},
+            {ab: ['four', 'five', 'six']},
+            {a: ['seven']},
+            {ab: ['eight', 'nine']},
+          ],
+        });
+      });
+
+      test('merges multiple marked delta', () => {
+        content[1].common = true;
+        content[2].common = true;
+        assert.deepEqual(element._translateChunksToIgnore({content}), {
+          content: [
+            {ab: ['one', 'two', 'different three', 'four', 'five', 'six']},
+            {a: ['seven']},
+            {ab: ['eight', 'nine']},
+          ],
+        });
+      });
+
+      test('marked deletion chunks are omitted', () => {
+        content[4].common = true;
+        assert.deepEqual(element._translateChunksToIgnore({content}), {
+          content: [
+            {ab: ['one', 'two']},
+            {a: ['three'], b: ['different three']},
+            {b: ['four']},
+            {ab: ['five', 'six', 'eight', 'nine']},
+          ],
+        });
+      });
+
+      test('marked deltas can start shared chunks', () => {
+        content[0] = {a: ['one'], b: ['two'], common: true};
+        assert.deepEqual(element._translateChunksToIgnore({content}), {
+          content: [
+            {ab: ['two']},
+            {a: ['three'], b: ['different three']},
+            {b: ['four']},
+            {ab: ['five', 'six']},
+            {a: ['seven']},
+            {ab: ['eight', 'nine']},
+          ],
+        });
+      });
+    });
+  });
+</script>
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 375e598..78814d4 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
@@ -60,7 +60,7 @@
         align-items: center;
         display: flex;
         padding: .35em 1.5em;
-        width: 20em;
+        width: 25em;
       }
       .pref:hover {
         background-color: var(--hover-background-color);
@@ -149,6 +149,15 @@
               type="checkbox"
               on-tap="_handleAutomaticReviewTap">
         </div>
+        <div class="pref">
+          <label for="ignoreWhitespace">Ignore Whitespace</label>
+          <select id="ignoreWhitespace" on-change="_handleIgnoreWhitespaceChange">
+            <option value="IGNORE_NONE">None</option>
+            <option value="IGNORE_TRAILING">Trailing</option>
+            <option value="IGNORE_LEADING_AND_TRAILING">Leading & trailing</option>
+            <option value="IGNORE_ALL">All</option>
+          </select>
+        </div>
       </div>
       <div class="actions">
         <gr-button id="cancelButton" link on-tap="_handleCancel">
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 47a3c2d..8fc90b9 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
@@ -67,6 +67,7 @@
       this.$.lineWrappingInput.checked = prefs.line_wrapping;
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
       this.$.automaticReviewInput.checked = !prefs.manual_review;
+      this.$.ignoreWhitespace.value = prefs.ignore_whitespace;
     },
 
     _localPrefsChanged(changeRecord) {
@@ -79,6 +80,11 @@
       this.set('_newPrefs.context', parseInt(selectEl.value, 10));
     },
 
+    _handleIgnoreWhitespaceChange(e) {
+      const selectEl = Polymer.dom(e).rootTarget;
+      this.set('_newPrefs.ignore_whitespace', selectEl.value);
+    },
+
     _handleShowTabsTap(e) {
       this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
     },
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 edee1ae..2e56871 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
@@ -36,7 +36,7 @@
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-mode-selector/gr-diff-mode-selector.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-diff-host/gr-diff-host.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
 
 <dom-module id="gr-diff-view">
@@ -171,7 +171,6 @@
         }
         .fullFileName {
           display: block;
-          font-size: var(--font-size-small);
           font-style: italic;
           min-width: 50%;
           padding: 0 .1em;
@@ -321,8 +320,8 @@
       </div>
     </gr-fixed-panel>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <gr-diff
-        id="diff"
+    <gr-diff-host
+        id="diffHost"
         hidden
         hidden$="[[_loading]]"
         class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
@@ -338,7 +337,7 @@
         view-mode="[[_diffMode]]"
         is-blame-loaded="{{_isBlameLoaded}}"
         on-line-selected="_onLineSelected">
-    </gr-diff>
+    </gr-diff-host>
     <gr-diff-preferences
         id="diffPreferences"
         prefs="{{_prefs}}"
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 9c27bae..b0eb423 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
@@ -192,6 +192,7 @@
       ',': '_handleCommaKey',
       'm': '_handleMKey',
       'r': '_handleRKey',
+      'shift+x': '_handleShiftXKey',
     },
 
     attached() {
@@ -199,7 +200,7 @@
         this._loggedIn = loggedIn;
       });
 
-      this.$.cursor.push('diffs', this.$.diff);
+      this.$.cursor.push('diffs', this.$.diffHost);
     },
 
     _getLoggedIn() {
@@ -275,7 +276,7 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = false;
+      this.$.diffHost.displayLine = false;
     },
 
     _handleShiftLeftKey(e) {
@@ -302,7 +303,7 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveUp();
     },
 
@@ -316,7 +317,7 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveDown();
     },
 
@@ -349,13 +350,13 @@
 
     _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (this.$.diff.isRangeSelected()) { return; }
+      if (this.$.diffHost.isRangeSelected()) { return; }
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
       const line = this.$.cursor.getTargetLineElement();
       if (line) {
-        this.$.diff.addDraftAtLine(line);
+        this.$.diffHost.addDraftAtLine(line);
       }
     },
 
@@ -406,7 +407,7 @@
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
         e.preventDefault();
-        this.$.diff.toggleLeftDiff();
+        this.$.diffHost.toggleLeftDiff();
         return;
       }
 
@@ -548,7 +549,7 @@
         this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
       }
 
-      this.$.diff.lineOfInterest = this._getLineOfInterest(this.params);
+      this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
       this._initCursor(this.params);
 
       this._changeNum = value.changeNum;
@@ -620,8 +621,8 @@
           });
         }
         this._loading = false;
-        this.$.diff.comments = this._commentsForDiff;
-        return this.$.diff.reload();
+        this.$.diffHost.comments = this._commentsForDiff;
+        return this.$.diffHost.reload();
       }).then(() => {
         this.$.reporting.diffViewDisplayed();
       });
@@ -948,13 +949,13 @@
      */
     _toggleBlame() {
       if (this._isBlameLoaded) {
-        this.$.diff.clearBlame();
+        this.$.diffHost.clearBlame();
         return;
       }
 
       this._isBlameLoading = true;
       this.fire('show-alert', {message: MSG_LOADING_BLAME});
-      this.$.diff.loadBlame()
+      this.$.diffHost.loadBlame()
           .then(() => {
             this._isBlameLoading = false;
             this.fire('show-alert', {message: MSG_LOADED_BLAME});
@@ -987,5 +988,10 @@
       }
       return '';
     },
+
+    _handleShiftXKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this.$.diffHost.expandAllContext();
+    },
   });
 })();
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 620286b..00527e4 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
@@ -73,7 +73,7 @@
 
     test('params change triggers diffViewDisplayed()', () => {
       sandbox.stub(element.$.reporting, 'diffViewDisplayed');
-      sandbox.stub(element.$.diff, 'reload').returns(Promise.resolve());
+      sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sandbox.spy(element, '_paramsChanged');
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -89,7 +89,8 @@
     });
 
     test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+      const toggleLeftDiffStub = sandbox.stub(
+          element.$.diffHost, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
@@ -168,7 +169,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
-      const computeContainerClassStub = sandbox.stub(element.$.diff,
+      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
@@ -188,6 +189,13 @@
       assert.equal(element._setReviewed.lastCall.args[0], true);
     });
 
+    test('shift+x shortcut expands all diff context', () => {
+      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      flushAsynchronousOperations();
+      assert.isTrue(expandStub.called);
+    });
+
     test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
@@ -543,7 +551,7 @@
       const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
           () => Promise.resolve());
 
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -568,7 +576,7 @@
     test('file review status', () => {
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
 
       element._loggedIn = true;
       element.params = {
@@ -614,7 +622,7 @@
     });
 
     test('hash is determined from params', done => {
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       sandbox.stub(element, '_initCursor');
 
       element._loggedIn = true;
@@ -635,7 +643,7 @@
 
     test('diff mode selector correctly toggles the diff', () => {
       const select = element.$.modeSelect;
-      const diffDisplay = element.$.diff;
+      const diffDisplay = element.$.diffHost;
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -680,7 +688,7 @@
 
     suite('_commitRange', () => {
       setup(() => {
-        sandbox.stub(element.$.diff, 'reload');
+        sandbox.stub(element.$.diffHost, 'reload');
         sandbox.stub(element, '_initCursor');
         sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
           _number: 42,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 978058f..44bb52a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -25,8 +25,10 @@
     this.highlights = [];
   }
 
+  /** @type {number|string} */
   GrDiffLine.prototype.afterNumber = 0;
 
+  /** @type {number|string} */
   GrDiffLine.prototype.beforeNumber = 0;
 
   GrDiffLine.prototype.contextGroup = null;
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 540df98..bddfc6d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -18,9 +18,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="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.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">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
@@ -40,7 +38,7 @@
       }
       .diffContainer {
         display: flex;
-        font: var(--font-size-small) var(--monospace-font-family);
+        font-family: var(--monospace-font-family);
         @apply --diff-container-styles;
       }
       .diffContainer.hiddenscroll {
@@ -58,8 +56,11 @@
         text-align: center;
       }
       .image-diff img {
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         max-width: 50em;
-        outline: 1px solid var(--border-color);
+      }
+      .image-diff .right.lineNum {
+        border-left: 1px solid var(--border-color);
       }
       .image-diff label,
       .binary-diff label {
@@ -79,6 +80,9 @@
       .content {
         background-color: var(--view-background-color);
       }
+      .image-diff .content {
+        background-color: var(--table-header-background-color);
+      }
       .full-width {
         width: 100%;
       }
@@ -89,7 +93,7 @@
       .lineNum,
       .content {
         /* Set font size based the user's diff preference. */
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         vertical-align: top;
         white-space: pre;
       }
@@ -185,18 +189,23 @@
         border-bottom: 1px solid var(--border-color);
         color: var(--link-color);
         font-family: var(--monospace-font-family);
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         padding: 0.5em 0 0.5em 4em;
       }
+      #loadingError,
       #sizeWarning {
         display: none;
         margin: 1em auto;
         max-width: 60em;
         text-align: center;
       }
+      #loadingError {
+        color: var(--error-text-color);
+      }
       #sizeWarning gr-button {
         margin: 1em;
       }
+      #loadingError.showError,
       #sizeWarning.warn {
         display: block;
       }
@@ -209,7 +218,7 @@
       td.blame {
         display: none;
         font-family: var(--font-family);
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
         padding: 0 .5em;
         white-space: pre;
       }
@@ -235,7 +244,7 @@
       /** Since the line limit position is determined by charachter size, blank
        lines also need to have the same font size as everything else */
       .full-width .blank {
-        font-size: var(--font-size, var(--font-size-small));
+        font-size: var(--font-size, var(--font-size-normal));
       }
       /** Support the line length indicator **/
       .full-width td.content,
@@ -245,6 +254,13 @@
         background-position: var(--line-limit) 0;
         background-repeat: repeat-y;
       }
+      .newlineWarning {
+        color: var(--deemphasized-text-color);
+        text-align: center;
+      }
+      .newlineWarning.hidden {
+        display: none;
+      }
     </style>
     <style include="gr-syntax-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
@@ -254,27 +270,28 @@
         <div>[[item]]</div>
       </template>
     </div>
-    <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
+    <div class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
-      <gr-diff-selection diff="[[_diff]]">
+      <gr-diff-selection diff="[[diff]]">
         <gr-diff-highlight
             id="highlights"
-            logged-in="[[_loggedIn]]"
+            logged-in="[[loggedIn]]"
             comments="{{comments}}">
           <gr-diff-builder
               id="diffBuilder"
               comments="[[comments]]"
               project-name="[[projectName]]"
-              diff="[[_diff]]"
+              diff="[[diff]]"
               diff-path="[[path]]"
               change-num="[[changeNum]]"
               patch-num="[[patchRange.patchNum]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
-              base-image="[[_baseImage]]"
-              revision-image="[[_revisionImage]]"
+              base-image="[[baseImage]]"
+              revision-image="[[revisionImage]]"
               parent-index="[[_parentIndex]]"
+              create-comment-fn="[[_createThreadGroupFn]]"
               line-of-interest="[[lineOfInterest]]">
             <table
                 id="diffTable"
@@ -284,10 +301,16 @@
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
+    <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+      [[_newlineWarning]]
+    </div>
+    <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+      [[errorMessage]]
+    </div>
     <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
-        large (about [[_diffLength(_diff)]] lines).
+        large (about [[_diffLength]] lines).
       </p>
       <gr-button on-tap="_handleLimitedBypass">
         Render with limited context
@@ -296,8 +319,6 @@
         Render anyway (may be slow)
       </gr-button>
     </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
   <script src="gr-diff-line.js"></script>
   <script src="gr-diff-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index f280c33..3ae8806 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -21,11 +21,9 @@
   const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
       'of an edit.';
   const ERR_INVALID_LINE = 'Invalid line number: ';
-  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
-  const EVENT_AGAINST_PARENT = 'diff-against-parent';
-  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+  const NO_NEWLINE_BASE = 'No newline at end of base file.';
+  const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -61,6 +59,12 @@
      * @event diff-comments-modified
      */
 
+     /**
+      * Fired when a draft is added or edited.
+      *
+      * @event draft-interaction
+      */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -85,21 +89,17 @@
       },
       isImageDiff: {
         type: Boolean,
-        computed: '_computeIsImageDiff(_diff)',
-        notify: true,
       },
       commitRange: Object,
-      filesWeblinks: {
-        type: Object,
-        value() { return {}; },
-        notify: true,
-      },
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
       },
       noRenderOnPrefsChange: Boolean,
-      comments: Object,
+      comments: {
+        type: Object,
+        value: {left: [], right: []},
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -120,24 +120,33 @@
        */
       lineOfInterest: Object,
 
-      _loggedIn: {
+      loading: {
+        type: Boolean,
+        value: false,
+        observer: '_loadingChanged',
+      },
+
+      loggedIn: {
         type: Boolean,
         value: false,
       },
-      _diff: Object,
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
       _diffHeaderItems: {
         type: Array,
         value: [],
-        computed: '_computeDiffHeaderItems(_diff.*)',
+        computed: '_computeDiffHeaderItems(diff.*)',
       },
       _diffTableClass: {
         type: String,
         value: '',
       },
       /** @type {?Object} */
-      _baseImage: Object,
+      baseImage: Object,
       /** @type {?Object} */
-      _revisionImage: Object,
+      revisionImage: Object,
 
       /**
        * Whether the safety check for large diffs when whole-file is set has
@@ -154,21 +163,40 @@
 
       _showWarning: Boolean,
 
-      /** @type {?Object} */
-      _blame: {
-        type: Object,
+      /** @type {?string} */
+      errorMessage: {
+        type: String,
         value: null,
       },
-      isBlameLoaded: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeIsBlameLoaded(_blame)',
+
+      /** @type {?Object} */
+      blame: {
+        type: Object,
+        value: null,
+        observer: '_blameChanged',
       },
 
       _parentIndex: {
         type: Number,
         computed: '_computeParentIndex(patchRange.*)',
       },
+
+      _newlineWarning: {
+        type: String,
+        computed: '_computeNewlineWarning(diff)',
+      },
+
+      /**
+       * @type {function(number, boolean, !string)}
+       */
+      _createThreadGroupFn: {
+        type: Function,
+        value() {
+          return this._createCommentThreadGroup.bind(this);
+        },
+      },
+
+      _diffLength: Number,
     },
 
     behaviors: [
@@ -182,41 +210,6 @@
       'create-comment': '_handleCreateComment',
     },
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
-
-    ready() {
-      if (this._canRender()) {
-        this.reload();
-      }
-    },
-
-    /** @return {!Promise} */
-    reload() {
-      this.cancel();
-      this.clearBlame();
-      this._safetyBypass = null;
-      this._showWarning = false;
-      this.clearDiffContent();
-
-      const promises = [];
-
-      promises.push(this._getDiff().then(diff => {
-        this._diff = diff;
-        return this._loadDiffAssets();
-      }));
-
-      return Promise.all(promises).then(() => {
-        if (this.prefs) {
-          return this._renderDiffTable();
-        }
-        return Promise.resolve();
-      });
-    },
-
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
@@ -240,37 +233,13 @@
       this.toggleClass('no-left');
     },
 
-    /**
-     * Load and display blame information for the base of the diff.
-     * @return {Promise} A promise that resolves when blame finishes rendering.
-     */
-    loadBlame() {
-      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
-          this.path, true)
-          .then(blame => {
-            if (!blame.length) {
-              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
-              return Promise.reject(MSG_EMPTY_BLAME);
-            }
-
-            this._blame = blame;
-
-            this.$.diffBuilder.setBlame(blame);
-            this.classList.add('showBlame');
-          });
-    },
-
-    _computeIsBlameLoaded(blame) {
-      return !!blame;
-    },
-
-    /**
-     * Unload blame information for the diff.
-     */
-    clearBlame() {
-      this._blame = null;
-      this.$.diffBuilder.setBlame(null);
-      this.classList.remove('showBlame');
+    _blameChanged(newValue) {
+      this.$.diffBuilder.setBlame(newValue);
+      if (newValue) {
+        this.classList.add('showBlame');
+      } else {
+        this.classList.remove('showBlame');
+      }
     },
 
     _handleCommentSaveOrDiscard() {
@@ -278,12 +247,6 @@
           {bubbles: true}));
     },
 
-    /** @return {boolean}} */
-    _canRender() {
-      return !!this.changeNum && !!this.patchRange && !!this.path &&
-          !this.noAutoRender;
-    },
-
     /** @return {!Array<!HTMLElement>} */
     getThreadEls() {
       let threads = [];
@@ -345,20 +308,18 @@
 
     addDraftAtLine(el) {
       this._selectLine(el);
-      this._isValidElForComment(el).then(valid => {
-        if (!valid) { return; }
+      if (!this._isValidElForComment(el)) { return; }
 
-        const value = el.getAttribute('data-value');
-        let lineNum;
-        if (value !== GrDiffLine.FILE) {
-          lineNum = parseInt(value, 10);
-          if (isNaN(lineNum)) {
-            this.fire('show-alert', {message: ERR_INVALID_LINE + value});
-            return;
-          }
+      const value = el.getAttribute('data-value');
+      let lineNum;
+      if (value !== GrDiffLine.FILE) {
+        lineNum = parseInt(value, 10);
+        if (isNaN(lineNum)) {
+          this.fire('show-alert', {message: ERR_INVALID_LINE + value});
+          return;
         }
-        this._createComment(el, lineNum);
-      });
+      }
+      this._createComment(el, lineNum);
     },
 
     _handleCreateComment(e) {
@@ -366,36 +327,34 @@
       const side = e.detail.side;
       const lineNum = range.endLine;
       const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-      this._isValidElForComment(lineEl).then(valid => {
-        if (!valid) { return; }
 
+      if (this._isValidElForComment(lineEl)) {
         this._createComment(lineEl, lineNum, side, range);
-      });
+      }
     },
 
+    /** @return {boolean} */
     _isValidElForComment(el) {
-      return this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          this.fire('show-auth-required');
-          return false;
-        }
-        const patchNum = el.classList.contains(DiffSide.LEFT) ?
-            this.patchRange.basePatchNum :
-            this.patchRange.patchNum;
+      if (!this.loggedIn) {
+        this.fire('show-auth-required');
+        return false;
+      }
+      const patchNum = el.classList.contains(DiffSide.LEFT) ?
+          this.patchRange.basePatchNum :
+          this.patchRange.patchNum;
 
-        const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
-        const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
-            this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+      const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
+      const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
+          this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
 
-        if (isEdit) {
-          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
-          return false;
-        } else if (isEditBase) {
-          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
-          return false;
-        }
-        return true;
-      });
+      if (isEdit) {
+        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
+        return false;
+      } else if (isEditBase) {
+        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
+        return false;
+      }
+      return true;
     },
 
     /**
@@ -405,6 +364,7 @@
      * @param {!Object=} opt_range
      */
     _createComment(lineEl, opt_lineNum, opt_side, opt_range) {
+      this.dispatchEvent(new CustomEvent('draft-interaction', {bubbles: true}));
       const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       const contentEl = contentText.parentElement;
       const side = opt_side ||
@@ -435,20 +395,6 @@
     },
 
     /**
-     * @param {string} commentSide
-     * @param {!Object=} opt_range
-     */
-    _getRangeString(commentSide, opt_range) {
-      return opt_range ?
-        'range-' +
-        opt_range.startLine + '-' +
-        opt_range.startChar + '-' +
-        opt_range.endLine + '-' +
-        opt_range.endChar + '-' +
-        commentSide : 'line-' + commentSide;
-    },
-
-    /**
      * Gets or creates a comment thread for a specific spot on a diff.
      * May include a range, if the comment is a range comment.
      *
@@ -464,8 +410,8 @@
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
-        threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-            this.changeNum, patchNum, this.path, isOnParent, commentSide);
+        threadGroupEl = this._createCommentThreadGroup(patchNum, isOnParent,
+            commentSide);
         contentEl.appendChild(threadGroupEl);
       }
 
@@ -480,6 +426,25 @@
     },
 
     /**
+     * @param {number} patchNum
+     * @param {boolean} isOnParent
+     * @param {!string} commentSide
+     * @return {!Object}
+     */
+    _createCommentThreadGroup(patchNum, isOnParent, commentSide) {
+      const threadGroupEl =
+          document.createElement('gr-diff-comment-thread-group');
+      threadGroupEl.changeNum = this.changeNum;
+      threadGroupEl.commentSide = commentSide;
+      threadGroupEl.patchForNewThreads = patchNum;
+      threadGroupEl.path = this.path;
+      threadGroupEl.isOnParent = isOnParent;
+      threadGroupEl.projectName = this.projectName;
+      threadGroupEl.parentIndex = this._parentIndex;
+      return threadGroupEl;
+    },
+
+    /**
      * The value to be used for the patch number of new comments created at the
      * given line and content elements.
      *
@@ -615,6 +580,17 @@
       this._prefsChanged(this.prefs);
     },
 
+    /** @param {boolean} newValue */
+    _loadingChanged(newValue) {
+      if (newValue) {
+        this.cancel();
+        this._blame = null;
+        this._safetyBypass = null;
+        this._showWarning = false;
+        this.clearDiffContent();
+      }
+    },
+
     _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
     },
@@ -622,7 +598,7 @@
     _prefsChanged(prefs) {
       if (!prefs) { return; }
 
-      this.clearBlame();
+      this._blame = null;
 
       const stylesToUpdate = {};
 
@@ -643,21 +619,33 @@
 
       this.updateStyles(stylesToUpdate);
 
-      if (this._diff && this.comments && !this.noRenderOnPrefsChange) {
+      if (this.diff && this.comments && !this.noRenderOnPrefsChange) {
+        this._renderDiffTable();
+      }
+    },
+
+    _diffChanged(newValue) {
+      if (newValue) {
+        this._diffLength = this.$.diffBuilder.getDiffLength();
         this._renderDiffTable();
       }
     },
 
     _renderDiffTable() {
+      if (!this.prefs) {
+        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        return;
+      }
       if (this.prefs.context === -1 &&
-          this._diffLength(this._diff) >= LARGE_DIFF_THRESHOLD_LINES &&
+          this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
           this._safetyBypass === null) {
         this._showWarning = true;
-        return Promise.resolve();
+        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        return;
       }
 
       this._showWarning = false;
-      return this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
+      this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
     /**
@@ -674,117 +662,6 @@
       this.$.diffTable.innerHTML = null;
     },
 
-    _handleGetDiffError(response) {
-      // Loading the diff may respond with 409 if the file is too large. In this
-      // case, use a toast error..
-      if (response.status === 409) {
-        this.fire('server-error', {response});
-        return;
-      }
-      this.fire('page-error', {response});
-    },
-
-    /** @return {!Promise<!Object>} */
-    _getDiff() {
-      return this.$.restAPI.getDiff(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path,
-          this._handleGetDiffError.bind(this)).then(diff => {
-            this._reportDiff(diff);
-            if (!this.commitRange) {
-              this.filesWeblinks = {};
-              return diff;
-            }
-            this.filesWeblinks = {
-              meta_a: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.baseCommit, this.path,
-                  {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
-              meta_b: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.commit, this.path,
-                  {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
-            };
-            return diff;
-          });
-    },
-
-    /**
-     * Report info about the diff response.
-     */
-    _reportDiff(diff) {
-      if (!diff || !diff.content) { return; }
-
-      // Count the delta lines stemming from normal deltas, and from
-      // due_to_rebase deltas.
-      let nonRebaseDelta = 0;
-      let rebaseDelta = 0;
-      diff.content.forEach(chunk => {
-        if (chunk.ab) { return; }
-        const deltaSize = Math.max(
-            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
-        if (chunk.due_to_rebase) {
-          rebaseDelta += deltaSize;
-        } else {
-          nonRebaseDelta += deltaSize;
-        }
-      });
-
-      // Find the percent of the delta from due_to_rebase chunks rounded to two
-      // digits. Diffs with no delta are considered 0%.
-      const totalDelta = rebaseDelta + nonRebaseDelta;
-      const percentRebaseDelta = !totalDelta ? 0 :
-          Math.round(100 * rebaseDelta / totalDelta);
-
-      // Report the due_to_rebase percentage in the "diff" category when
-      // applicable.
-      if (this.patchRange.basePatchNum === 'PARENT') {
-        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
-      } else if (percentRebaseDelta === 0) {
-        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
-      } else {
-        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
-            percentRebaseDelta);
-      }
-    },
-
-    /** @return {!Promise} */
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    /** @return {boolean} */
-    _computeIsImageDiff() {
-      if (!this._diff) { return false; }
-
-      const isA = this._diff.meta_a &&
-          this._diff.meta_a.content_type.startsWith('image/');
-      const isB = this._diff.meta_b &&
-          this._diff.meta_b.content_type.startsWith('image/');
-
-      return !!(this._diff.binary && (isA || isB));
-    },
-
-    /** @return {!Promise} */
-    _loadDiffAssets() {
-      if (this.isImageDiff) {
-        return this._getImages().then(images => {
-          this._baseImage = images.baseImage;
-          this._revisionImage = images.revisionImage;
-        });
-      } else {
-        this._baseImage = null;
-        this._revisionImage = null;
-        return Promise.resolve();
-      }
-    },
-
-    /** @return {!Promise} */
-    _getImages() {
-      return this.$.restAPI.getImagesForDiff(this.changeNum, this._diff,
-          this.patchRange);
-    },
-
     _projectConfigChanged(projectConfig) {
       const threadEls = this.getThreadEls();
       for (let i = 0; i < threadEls.length; i++) {
@@ -795,12 +672,13 @@
     /** @return {!Array} */
     _computeDiffHeaderItems(diffInfoRecord) {
       const diffInfo = diffInfoRecord.base;
-      if (!diffInfo || !diffInfo.diff_header || diffInfo.binary) { return []; }
+      if (!diffInfo || !diffInfo.diff_header) { return []; }
       return diffInfo.diff_header.filter(item => {
         return !(item.startsWith('diff --git ') ||
             item.startsWith('index ') ||
             item.startsWith('+++ ') ||
-            item.startsWith('--- '));
+            item.startsWith('--- ') ||
+            item === 'Binary files differ');
       });
     },
 
@@ -809,25 +687,6 @@
       return items.length === 0;
     },
 
-    /**
-     * The number of lines in the diff. For delta chunks that are different
-     * sizes on the left and the right, the longer side is used.
-     * @param {!Object} diff
-     * @return {number}
-     */
-    _diffLength(diff) {
-      return diff.content.reduce((sum, sec) => {
-        if (sec.hasOwnProperty('ab')) {
-          return sum + sec.ab.length;
-        } else {
-          return sum + Math.max(
-              sec.hasOwnProperty('a') ? sec.a.length : 0,
-              sec.hasOwnProperty('b') ? sec.b.length : 0
-          );
-        }
-      }, 0);
-    },
-
     _handleFullBypass() {
       this._safetyBypass = FULL_CONTEXT;
       this._renderDiffTable();
@@ -844,6 +703,14 @@
     },
 
     /**
+     * @param {string} errorMessage
+     * @return {string}
+     */
+    _computeErrorClass(errorMessage) {
+      return errorMessage ? 'showError' : '';
+    },
+
+    /**
      * @return {number|null}
      */
     _computeParentIndex(patchRangeRecord) {
@@ -852,5 +719,93 @@
       }
       return this.getParentIndex(patchRangeRecord.base.basePatchNum);
     },
+
+    expandAllContext() {
+      this._handleFullBypass();
+    },
+
+    /**
+     * Find the last chunk for the given side.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {Object|null} returns the chunk object or null if there was
+     *     no chunk for that side.
+     */
+    _lastChunkForSide(diff, leftSide) {
+      if (!diff.content.length) { return null; }
+
+      let chunkIndex = diff.content.length;
+      let chunk;
+
+      // Walk backwards until we find a chunk for the given side.
+      do {
+        chunkIndex--;
+        chunk = diff.content[chunkIndex];
+      } while (
+          // We haven't reached the beginning.
+          chunkIndex >= 0 &&
+
+          // The chunk doesn't have both sides.
+          !chunk.ab &&
+
+          // The chunk doesn't have the given side.
+          ((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
+
+      // If we reached the beginning of the diff and failed to find a chunk
+      // with the given side, return null.
+      if (chunkIndex === -1) { return null; }
+
+      return chunk;
+    },
+
+    /**
+     * Check whether the specified side of the diff has a trailing newline.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {boolean|null} Return true if the side has a trailing newline.
+     *     Return false if it doesn't. Return null if not applicable (for
+     *     example, if the diff has no content on the specified side).
+     */
+    _hasTrailingNewlines(diff, leftSide) {
+      const chunk = this._lastChunkForSide(diff, leftSide);
+      if (!chunk) { return null; }
+      let lines;
+      if (chunk.ab) {
+        lines = chunk.ab;
+      } else {
+        lines = leftSide ? chunk.a : chunk.b;
+      }
+      return lines[lines.length - 1] === '';
+    },
+
+    /**
+     * @param {!Object} diff
+     * @return {string|null}
+     */
+    _computeNewlineWarning(diff) {
+      const hasLeft = this._hasTrailingNewlines(diff, true);
+      const hasRight = this._hasTrailingNewlines(diff, false);
+      const messages = [];
+      if (hasLeft === false) {
+        messages.push(NO_NEWLINE_BASE);
+      }
+      if (hasRight === false) {
+        messages.push(NO_NEWLINE_REVISION);
+      }
+      if (!messages.length) { return null; }
+      return messages.join(' — ');
+    },
+
+    /**
+     * @param {string} warning
+     * @param {boolean} loading
+     * @return {string}
+     */
+    _computeNewlineWarningClass(warning, loading) {
+      if (loading || !warning) { return 'newlineWarning hidden'; }
+      return 'newlineWarning';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 33abab4..faf529b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -48,29 +48,13 @@
       sandbox.restore();
     });
 
-    test('reload cancels before network resolves', () => {
-      element = fixture('basic');
-      const cancelStub = sandbox.stub(element, 'cancel');
-
-      // Stub the network calls into requests that never resolve.
-      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
-
-      element.reload();
-      assert.isTrue(cancelStub.called);
-    });
-
     test('cancel', () => {
+      element = fixture('basic');
       const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
       element.cancel();
       assert.isTrue(cancelStub.calledOnce);
     });
 
-    test('_diffLength', () => {
-      element = fixture('basic');
-      const mock = document.createElement('mock-diff-response');
-      assert.equal(element._diffLength(mock.diffResponse), 52);
-    });
-
     test('line limit with line_wrapping', () => {
       element = fixture('basic');
       element.prefs = {line_wrapping: true, line_length: 80, tab_size: 2};
@@ -172,10 +156,12 @@
 
     suite('not logged in', () => {
       setup(() => {
+        const getLoggedInPromise = Promise.resolve(false);
         stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(false); },
+          getLoggedIn() { return getLoggedInPromise; },
         });
         element = fixture('basic');
+        return getLoggedInPromise;
       });
 
       test('toggleLeftDiff', () => {
@@ -185,15 +171,12 @@
         assert.isFalse(element.classList.contains('no-left'));
       });
 
-      test('addDraftAtLine', done => {
+      test('addDraftAtLine', () => {
         sandbox.stub(element, '_selectLine');
         const loggedInErrorSpy = sandbox.spy();
         element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addDraftAtLine();
-        flush(() => {
-          assert.isTrue(loggedInErrorSpy.called);
-          done();
-        });
+        assert.isTrue(loggedInErrorSpy.called);
       });
 
       test('view does not start with displayLine classList', () => {
@@ -209,39 +192,6 @@
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('loads files weblinks', () => {
-        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-            .returns({name: 'stubb', url: '#s'});
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({}));
-        element.projectName = 'test-project';
-        element.path = 'test-path';
-        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-        element.patchRange = {};
-        return element._getDiff().then(() => {
-          assert.isTrue(weblinksStub.calledTwice);
-          assert.isTrue(weblinksStub.firstCall.calledWith({
-            commit: 'test-base',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.isTrue(weblinksStub.secondCall.calledWith({
-            commit: 'test-commit',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.deepEqual(element.filesWeblinks, {
-            meta_a: [{name: 'stubb', url: '#s'}],
-            meta_b: [{name: 'stubb', url: '#s'}],
-          });
-        });
-      });
-
       test('remove comment', () => {
         element.comments = {
           meta: {
@@ -343,20 +293,6 @@
         });
       });
 
-      test('_getRangeString', () => {
-        const side = 'PARENT';
-        const range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 2,
-        };
-        assert.equal(element._getRangeString(side, range),
-            'range-1-1-1-2-PARENT');
-        assert.equal(element._getRangeString(side, null),
-            'line-PARENT');
-      }),
-
       test('thread groups', () => {
         const contentEl = document.createElement('div');
         const commentSide = 'left';
@@ -410,7 +346,6 @@
       suite('image diffs', () => {
         let mockFile1;
         let mockFile2;
-        const stubs = [];
         setup(() => {
           mockFile1 = {
             body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
@@ -422,66 +357,29 @@
             'wsAAAAAAAAAAAAA/////w==',
             type: 'image/bmp',
           };
-          const mockCommit = {
-            commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
-            parents: [{
-              commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
-              subject: 'Added a carrot',
-            }],
-            author: {
-              name: 'Wyatt Allen',
-              email: 'wyatta@google.com',
-              date: '2016-05-23 21:44:51.000000000',
-              tz: -420,
-            },
-            committer: {
-              name: 'Wyatt Allen',
-              email: 'wyatta@google.com',
-              date: '2016-05-25 00:25:41.000000000',
-              tz: -420,
-            },
-            subject: 'Updated the carrot',
-            message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
-          };
-          const mockComments = {baseComments: [], comments: []};
-
-          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-              () => Promise.resolve(mockCommit)));
-          stubs.push(sandbox.stub(element.$.restAPI,
-              'getB64FileContents',
-              (changeId, patchNum, path, opt_parentIndex) => {
-                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
-                    mockFile2);
-              }));
-          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-              () => Promise.resolve(mockComments)));
-          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-              () => Promise.resolve(mockComments)));
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
           element.comments = {left: [], right: []};
+          element.isImageDiff = true;
+          element.prefs = {
+            auto_hide_diff_table_header: true,
+            context: 10,
+            cursor_blink_rate: 0,
+            font_size: 12,
+            ignore_whitespace: 'IGNORE_NONE',
+            intraline_difference: true,
+            line_length: 100,
+            line_wrapping: false,
+            show_line_endings: true,
+            show_tabs: true,
+            show_whitespace_errors: true,
+            syntax_highlighting: true,
+            tab_size: 8,
+            theme: 'DEFAULT',
+          };
         });
 
         test('renders image diffs with same file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
-
           const rendered = () => {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
@@ -536,10 +434,24 @@
 
           element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.revisionImage = mockFile2;
+          element.diff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
         });
 
         test('renders image diffs with a different file name', done => {
@@ -559,8 +471,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           const rendered = () => {
             // Recognizes that it should be an image diff.
@@ -618,10 +528,11 @@
 
           element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.baseImage._name = mockDiff.meta_a.name;
+          element.revisionImage = mockFile2;
+          element.revisionImage._name = mockDiff.meta_b.name;
+          element.diff = mockDiff;
         });
 
         test('renders added image', done => {
@@ -640,8 +551,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -657,10 +566,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.revisionImage = mockFile2;
+          element.diff = mockDiff;
         });
 
         test('renders removed image', done => {
@@ -679,8 +586,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -696,10 +601,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.diff = mockDiff;
         });
 
         test('does not render disallowed image type', done => {
@@ -720,9 +623,6 @@
           };
           mockFile1.type = 'image/jpeg-evil';
 
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
-
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
@@ -733,10 +633,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.diff = mockDiff;
         });
       });
 
@@ -783,20 +681,10 @@
         content.click();
       });
 
-      test('_getDiff handles null diff responses', done => {
-        stub('gr-rest-api-interface', {
-          getDiff() { return Promise.resolve(null); },
-        });
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-        element._getDiff().then(done);
-      });
-
       suite('getCursorStops', () => {
         const setupDiff = function() {
           const mock = document.createElement('mock-diff-response');
-          element._diff = mock.diffResponse;
+          element.diff = mock.diffResponse;
           element.comments = {
             left: [],
             right: [],
@@ -845,14 +733,8 @@
     suite('logged in', () => {
       let fakeLineEl;
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getPreferences() {
-            return Promise.resolve({time_format: 'HHMM_12'});
-          },
-          getAccountCapabilities() { return Promise.resolve(); },
-        });
         element = fixture('basic');
+        element.loggedIn = true;
         element.patchRange = {};
 
         fakeLineEl = {
@@ -863,58 +745,40 @@
         };
       });
 
-      test('addDraftAtLine', done => {
+      test('addDraftAtLine', () => {
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(element._createComment
-              .calledWithExactly(fakeLineEl, 42));
-          done();
-        });
+        assert.isTrue(element._createComment
+            .calledWithExactly(fakeLineEl, 42));
       });
 
-      test('addDraftAtLine on an edit', done => {
+      test('addDraftAtLine on an edit', () => {
         element.patchRange.basePatchNum = element.EDIT_NAME;
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
         const alertSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addEventListener('show-alert', alertSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(alertSpy.called);
-          assert.isFalse(element._createComment.called);
-          done();
-        });
+        assert.isTrue(alertSpy.called);
+        assert.isFalse(element._createComment.called);
       });
 
-      test('addDraftAtLine on an edit base', done => {
+      test('addDraftAtLine on an edit base', () => {
         element.patchRange.patchNum = element.EDIT_NAME;
         element.patchRange.basePatchNum = element.PARENT_NAME;
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
         const alertSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addEventListener('show-alert', alertSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(alertSpy.called);
-          assert.isFalse(element._createComment.called);
-          done();
-        });
+        assert.isTrue(alertSpy.called);
+        assert.isFalse(element._createComment.called);
       });
 
       suite('change in preferences', () => {
         setup(() => {
-          element._diff = {
+          element.diff = {
             meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
             meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
               lines: 560},
@@ -1048,7 +912,8 @@
 
     suite('diff header', () => {
       setup(() => {
-        element._diff = {
+        element = fixture('basic');
+        element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
           meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
             lines: 560},
@@ -1061,21 +926,30 @@
 
       test('hidden', () => {
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '--- a/test.jpg');
+        element.push('diff.diff_header', '--- a/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '+++ b/test.jpg');
+        element.push('diff.diff_header', '+++ b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'test');
+        element.push('diff.diff_header', 'test');
         assert.equal(element._diffHeaderItems.length, 1);
         flushAsynchronousOperations();
 
         assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-        element.set('_diff.binary', true);
+      });
+
+      test('binary files', () => {
+        element.diff.binary = true;
         assert.equal(element._diffHeaderItems.length, 0);
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('diff.diff_header', 'test');
+        assert.equal(element._diffHeaderItems.length, 1);
+        element.push('diff.diff_header', 'Binary files differ');
+        assert.equal(element._diffHeaderItems.length, 1);
       });
     });
 
@@ -1085,39 +959,47 @@
       setup(() => {
         element = fixture('basic');
         renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-            () => Promise.resolve());
+            () => {
+              Promise.resolve();
+              element.$.diffBuilder.dispatchEvent(
+                  new CustomEvent('render', {bubbles: true}));
+            });
         const mock = document.createElement('mock-diff-response');
-        element._diff = mock.diffResponse;
+        sandbox.stub(element.$.diffBuilder, 'getDiffLength').returns(10000);
+        element.diff = mock.diffResponse;
         element.comments = {left: [], right: []};
         element.noRenderOnPrefsChange = true;
       });
 
-      test('lage render w/ context = 10', () => {
+      test('large render w/ context = 10', done => {
         element.prefs = {context: 10};
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and bypass', () => {
+      test('large render w/ whole file and bypass', done => {
         element.prefs = {context: -1};
         element._safetyBypass = 10;
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and no bypass', () => {
+      test('large render w/ whole file and no bypass', done => {
         element.prefs = {context: -1};
-        sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isFalse(renderStub.called);
           assert.isTrue(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
     });
 
@@ -1126,144 +1008,144 @@
         element = fixture('basic');
       });
 
-      test('clearBlame', () => {
-        element._blame = [];
+      test('unsetting', () => {
+        element.blame = [];
         const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
         element.classList.add('showBlame');
-        element.clearBlame();
-        assert.isNull(element._blame);
+        element.blame = null;
         assert.isTrue(setBlameSpy.calledWithExactly(null));
         assert.isFalse(element.classList.contains('showBlame'));
       });
 
-      test('loadBlame', () => {
+      test('setting', () => {
         const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame().then(() => {
-          assert.isTrue(getBlameStub.calledWithExactly(
-              42, 5, 'foo/bar.baz', true));
-          assert.isFalse(showAlertStub.called);
-          assert.equal(element._blame, mockBlame);
-          assert.isTrue(element.classList.contains('showBlame'));
-        });
-      });
-
-      test('loadBlame empty', () => {
-        const mockBlame = [];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame()
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(() => {
-              assert.isTrue(showAlertStub.calledOnce);
-              assert.isNull(element._blame);
-              assert.isFalse(element.classList.contains('showBlame'));
-            });
+        element.blame = mockBlame;
+        assert.isTrue(element.classList.contains('showBlame'));
       });
     });
 
-    suite('_reportDiff', () => {
-      let reportStub;
-
+    suite('trailing newlines', () => {
       setup(() => {
         element = fixture('basic');
-        element.patchRange = {basePatchNum: 1};
-        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
       });
 
-      test('null and content-less', () => {
-        element._reportDiff(null);
-        assert.isFalse(reportStub.called);
+      suite('_lastChunkForSide', () => {
+        test('deltas', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
 
-        element._reportDiff({});
-        assert.isFalse(reportStub.called);
+          diff.content.push({a: ['foo'], b: ['bar']});
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+        });
+
+        test('addition', () => {
+          const diff = {content: [
+            {b: ['foo', 'bar', 'baz']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar', 'baz']},
+          ]};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+        });
+
+        test('empty', () => {
+          const diff = {content: []};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
       });
 
-      test('diff w/ no delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {ab: ['baz', 'foo']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+      suite('_hasTrailingNewlines', () => {
+        test('shared no trailing', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({ab: ['foo', 'bar']});
+          assert.isFalse(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('delta trailing in right', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({a: ['foo', 'bar'], b: ['baz', '']});
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('addition', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (leftSide) { return null; }
+            return {b: ['foo', '']};
+          });
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isNull(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (!leftSide) { return null; }
+            return {a: ['foo']};
+          });
+          assert.isNull(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
       });
 
-      test('diff w/ no rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+      test('_computeNewlineWarning', () => {
+        const NO_NEWLINE_BASE = 'No newline at end of base file.';
+        const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+        let hasLeft;
+        let hasRight;
+        sandbox.stub(element, '_hasTrailingNewlines', (diff, left) => {
+          if (left) { return hasLeft; }
+          return hasRight;
+        });
+        const diff = undefined;
+
+        // The revision has a trailing newline, but the base doesn't.
+        hasLeft = true;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_REVISION);
+
+        // Trailing newlines were undetermined in the revision.
+        hasLeft = true;
+        hasRight = null;
+        assert.isNull(element._computeNewlineWarning(diff));
+
+        // Missing trailing newlines in the base.
+        hasLeft = false;
+        hasRight = null;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_BASE);
+
+        // Missing trailing newlines in both.
+        hasLeft = false;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff),
+            NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
       });
 
-      test('diff w/ some rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 50);
-      });
-
-      test('diff w/ all rebase delta', () => {
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-          due_to_rebase: true,
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 100);
-      });
-
-      test('diff against parent event', () => {
-        element.patchRange.basePatchNum = 'PARENT';
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+      test('_computeNewlineWarningClass', () => {
+        const hidden = 'newlineWarning hidden';
+        const shown = 'newlineWarning';
+        assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+        assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', false), shown);
       });
     });
   });
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 6c7903d..db14fc8 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
@@ -72,7 +72,7 @@
 
     /**
      * Register a listener for layer updates.
-     * @param {Function<Number, Number, String>} fn The update handler function.
+     * @param {function(number, number, string)} fn The update handler function.
      *     Should accept as arguments the line numbers for the start and end of
      *     the update and the side as a string.
      */
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
index 0e028d8..41d3804 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
@@ -32,7 +32,8 @@
       .gr-syntax-meta {
         color: var(--syntax-meta-color);
       }
-      .gr-syntax-keyword {
+      .gr-syntax-keyword,
+      .gr-syntax-name {
         color: var(--syntax-keyword-color);
         line-height: 1;
       }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 61a9e69..81b3c07 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -23,7 +23,7 @@
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -46,10 +46,10 @@
         margin-left: 1em;
         text-decoration: none;
       }
-      gr-confirm-dialog {
+      gr-dialog {
         width: 50em;
       }
-      gr-confirm-dialog .main {
+      gr-dialog .main {
         width: 100%;
       }
       gr-autocomplete {
@@ -71,7 +71,7 @@
         width: 100%;
       }
       @media screen and (max-width: 50em) {
-        gr-confirm-dialog {
+        gr-dialog {
           width: 100vw;
         }
       }
@@ -84,7 +84,7 @@
           on-tap="_handleTap">[[action.label]]</gr-button>
     </template>
     <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-dialog
+      <gr-dialog
           id="openDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
@@ -101,8 +101,8 @@
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="deleteDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
@@ -117,8 +117,8 @@
               query="[[_query]]"
               text="{{_path}}"></gr-autocomplete>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="renameDialog"
           class="invisible dialog"
           disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
@@ -138,8 +138,8 @@
               bind-value="{{_newPath}}"
               placeholder="Enter the new path."/>
         </div>
-      </gr-confirm-dialog>
-      <gr-confirm-dialog
+      </gr-dialog>
+      <gr-dialog
           id="restoreDialog"
           class="invisible dialog"
           confirm-label="Restore"
@@ -153,7 +153,7 @@
               disabled
               bind-value="{{_path}}"/>
         </div>
-      </gr-confirm-dialog>
+      </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
index c2d6cdf..f80e9f8 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -51,7 +51,6 @@
       }
       header gr-editable-label {
         font-size: var(--font-size-large);
-        font-weight: bold;
         --label-style: {
           text-overflow: initial;
           white-space: initial;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index 46d5180..bf7fc99 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -155,7 +155,8 @@
 
       return this.$.restAPI.getFileContent(changeNum, path, patchNum)
           .then(res => {
-            if (storedContent && storedContent.message) {
+            if (storedContent && storedContent.message &&
+                storedContent.message !== res.content) {
               this.dispatchEvent(new CustomEvent('show-alert',
                   {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 4cf25fe..2f5332d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -376,6 +376,29 @@
       });
     });
 
+    test('local edit exists, is same as remote edit', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'pending edit',
+          }));
+
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isFalse(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'pending edit');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
     test('storage key computation', () => {
       element._changeNum = 1;
       element._patchNum = 1;
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index efcefe2..787a1c6 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -26,14 +26,20 @@
       passiveTouchGestures: true,
     };
   }
+  // Needed for JSCompiler to understand it's global.
+  // eslint-disable-next-line no-unused-vars, prefer-const
+  let Gerrit = window.Gerrit || {};
+  window.Gerrit = Gerrit;
 </script>
 
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
     reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
 
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 7acb680..0508608 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -99,6 +99,7 @@
       'page-error': '_handlePageError',
       'title-change': '_handleTitleChange',
       'location-change': '_handleLocationChange',
+      'rpc-log': '_handleRpcLog',
     },
 
     observers: [
@@ -127,6 +128,10 @@
       });
       this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
+
+        if (config && config.gerrit && config.gerrit.report_bug_url) {
+          this._feedbackUrl = config.gerrit.report_bug_url;
+        }
       });
       this.$.restAPI.getVersion().then(version => {
         this._version = version;
@@ -332,5 +337,15 @@
       console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
       console.groupEnd();
     },
+
+    /**
+     * Intercept RPC log events emitted by REST API interfaces.
+     * Note: the REST API interface cannot use gr-reporting directly because
+     * that would create a cyclic dependency.
+     */
+    _handleRpcLog(e) {
+      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+          e.detail.elapsed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 51ad0b7..0e8bb45 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -22,24 +22,35 @@
 
     properties: {
       name: String,
+      _urlsImported: {
+        type: Array,
+        value() { return []; },
+      },
+      _stylesApplied: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
     _import(url) {
+      if (this._urlsImported.includes(url)) { return Promise.resolve(); }
+      this._urlsImported.push(url);
       return new Promise((resolve, reject) => {
         this.importHref(url, resolve, reject);
       });
     },
 
     _applyStyle(name) {
+      if (this._stylesApplied.includes(name)) { return; }
+      this._stylesApplied.push(name);
       const s = document.createElement('style', 'custom-style');
       s.setAttribute('include', name);
       Polymer.dom(this.root).appendChild(s);
     },
 
-    ready() {
-      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
-          Gerrit._endpoints.getPlugins(this.name).map(
-              pluginUrl => this._import(pluginUrl)))
+    _importAndApply() {
+      Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
+          pluginUrl => this._import(pluginUrl))
       ).then(() => {
         const moduleNames = Gerrit._endpoints.getModules(this.name);
         for (const name of moduleNames) {
@@ -47,5 +58,13 @@
         }
       });
     },
+
+    attached() {
+      this._importAndApply();
+    },
+
+    ready() {
+      Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index d1893ba..ec2888d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -32,38 +32,90 @@
 
 <script>
   suite('gr-external-style integration tests', () => {
+    const TEST_URL = 'http://some/plugin/url.html';
+
     let sandbox;
     let element;
+    let plugin;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-
-      // NB: Order is important.
-      let plugin;
+    const installPlugin = () => {
+      if (plugin) { return; }
       Gerrit.install(p => {
         plugin = p;
-        plugin.registerStyleModule('foo', 'some-module');
-      }, '0.1', 'http://some/plugin/url.html');
+      }, '0.1', TEST_URL);
+    };
 
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
+    const createElement = () => {
       element = fixture('basic');
-      sandbox.stub(element, '_applyStyle');
-      sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
+      sandbox.spy(element, '_applyStyle');
+    };
 
-      flush(done);
+    /**
+     * Installs the plugin, creates the element, registers style module.
+     */
+    const lateRegister = () => {
+      installPlugin();
+      createElement();
+      plugin.registerStyleModule('foo', 'some-module');
+    };
+
+    /**
+     * Installs the plugin, registers style module, creates the element.
+     */
+    const earlyRegister = () => {
+      installPlugin();
+      plugin.registerStyleModule('foo', 'some-module');
+      createElement();
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-external-style', {
+        importHref: (url, resolve) => resolve(),
+      });
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('imports plugin-provided module', () => {
-      assert.isTrue(element.importHref.calledWith(
-          new URL('http://some/plugin/url.html')));
+    test('imports plugin-provided module', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
     });
 
-    test('applies plugin-provided styles', () => {
+    test('applies plugin-provided styles', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element._applyStyle.calledWith('some-module'));
+    });
+
+    test('does not double import', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const urlsImported =
+          element._urlsImported.filter(url => url.toString() === TEST_URL);
+      assert.strictEqual(urlsImported.length, 1);
+    });
+
+    test('does not double apply', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const stylesApplied =
+          element._stylesApplied.filter(name => name === 'some-module');
+      assert.strictEqual(stylesApplied.length, 1);
+    });
+
+    test('loads and applies preloaded modules', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
       assert.isTrue(element._applyStyle.calledWith('some-module'));
     });
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index c5bf21a..7476637 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -33,13 +33,16 @@
 
     _configChanged(config) {
       const plugins = config.plugin;
-      const htmlPlugins = plugins.html_resource_paths || [];
+      const htmlPlugins = (plugins.html_resource_paths || [])
+          .map(p => this._urlFor(p))
+          .filter(p => !Gerrit._isPluginPreloaded(p));
       const jsPlugins =
-          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
-      const defaultTheme = config.default_theme;
+          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins)
+          .map(p => this._urlFor(p))
+          .filter(p => !Gerrit._isPluginPreloaded(p));
+      const defaultTheme = this._urlFor(config.default_theme);
       const pluginsPending =
-          [].concat(jsPlugins, htmlPlugins, defaultTheme || []).map(
-              p => this._urlFor(p));
+          [].concat(jsPlugins, htmlPlugins, defaultTheme || []);
       Gerrit._setPluginsPending(pluginsPending);
       if (defaultTheme) {
         // Make theme first to be first to load.
@@ -95,8 +98,12 @@
     },
 
     _urlFor(pathOrUrl) {
-      if (pathOrUrl.startsWith('http')) {
-        // Plugins are loaded from another domain.
+      if (!pathOrUrl) {
+        return pathOrUrl;
+      }
+      if (pathOrUrl.startsWith('preloaded:') ||
+          pathOrUrl.startsWith('http')) {
+        // Plugins are loaded from another domain or preloaded.
         return pathOrUrl;
       }
       if (!pathOrUrl.startsWith('/')) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index 0880d73..26958ee 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -187,5 +187,33 @@
       element.importHref.secondCall.args[2]();
       assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
+
+    test('default theme is loaded with html plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      element.config = {
+        default_theme: '/oof',
+        plugin: {},
+      };
+      assert.isTrue(Gerrit._setPluginsPending.calledWith([url + '/oof']));
+    });
+
+    test('skips preloaded plugins', () => {
+      sandbox.stub(Gerrit, '_isPluginPreloaded')
+          .withArgs(url + '/plugins/foo/bar').returns(true)
+          .withArgs(url + '/plugins/42').returns(true);
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      sandbox.stub(element, '_createScriptTag');
+      element.config = {
+        plugin: {
+          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+          js_resource_paths: ['plugins/42'],
+        },
+      };
+      assert.isTrue(
+          Gerrit._setPluginsPending.calledWith([url + '/plugins/baz']));
+      assert.equal(element._createScriptTag.callCount, 0);
+      assert.isTrue(element.importHref.calledWith(url + '/plugins/baz'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index fa188d7..4f69513 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -37,6 +37,9 @@
         cursor: pointer;
         text-align: center;
       }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
       .checkboxContainer:hover {
         outline: 1px solid var(--border-color);
       }
@@ -52,12 +55,12 @@
         <tbody>
           <tr>
             <td>Number</td>
-            <td
-                class="checkboxContainer"
-                on-tap="_handleTargetTap">
+            <td class="checkboxContainer"
+                on-tap="_handleCheckboxContainerTap">
               <input
                   type="checkbox"
                   name="number"
+                  on-tap="_handleNumberCheckboxTap"
                   checked$="[[showNumber]]">
             </td>
           </tr>
@@ -65,10 +68,11 @@
             <tr>
               <td>[[item]]</td>
               <td class="checkboxContainer"
-                  on-tap="_handleTargetTap">
+                  on-tap="_handleCheckboxContainerTap">
                 <input
                     type="checkbox"
                     name="[[item]]"
+                    on-tap="_handleTargetTap"
                     checked$="[[!isColumnHidden(item, displayedColumns)]]">
               </td>
             </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 7b74096..7d109633 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -35,40 +35,42 @@
       Gerrit.ChangeTableBehavior,
     ],
 
-    _getButtonText(isShown) {
-      return isShown ? 'Hide' : 'Show';
-    },
-
-    _updateDisplayedColumns(displayedColumns, name, checked) {
-      if (!checked) {
-        return displayedColumns.filter(column => {
-          return name.toLowerCase() !== column.toLowerCase();
-        });
-      } else {
-        return displayedColumns.concat([name]);
-      }
+    /**
+     * Get the list of enabled column names from whichever checkboxes are
+     * checked (excluding the number checkbox).
+     * @return {!Array<string>}
+     */
+    _getDisplayedColumns() {
+      return Polymer.dom(this.root)
+          .querySelectorAll('.checkboxContainer input:not([name=number])')
+          .filter(checkbox => checkbox.checked)
+          .map(checkbox => checkbox.name);
     },
 
     /**
-     * Handles tap on either the checkbox itself or the surrounding table cell.
+     * Handle a tap on a checkbox container and relay the tap to the checkbox it
+     * contains.
+     */
+    _handleCheckboxContainerTap(e) {
+      const checkbox = Polymer.dom(e.target).querySelector('input');
+      if (!checkbox) { return; }
+      checkbox.click();
+    },
+
+    /**
+     * Handle a tap on the number checkbox and update the showNumber property
+     * accordingly.
+     */
+    _handleNumberCheckboxTap(e) {
+      this.showNumber = Polymer.dom(e).rootTarget.checked;
+    },
+
+    /**
+     * Handle a tap on a displayed column checkboxes (excluding number) and
+     * update the displayedColumns property accordingly.
      */
     _handleTargetTap(e) {
-      let checkbox = Polymer.dom(e.target).querySelector('input');
-      if (checkbox) {
-        checkbox.click();
-      } else {
-        // The target is the checkbox itself.
-        checkbox = Polymer.dom(e).rootTarget;
-      }
-
-      if (checkbox.name === 'number') {
-        this.showNumber = checkbox.checked;
-        return;
-      }
-
-      this.set('displayedColumns',
-          this._updateDisplayedColumns(
-              this.displayedColumns, checkbox.name, checkbox.checked));
+      this.set('displayedColumns', this._getDisplayedColumns());
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index daf9437..32fab9d 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -47,12 +47,13 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
       ];
 
       element.set('displayedColumns', columns);
+      element.showNumber = false;
       flushAsynchronousOperations();
     });
 
@@ -90,7 +91,7 @@
         'Status',
         'Owner',
         'Assignee',
-        'Project',
+        'Repo',
         'Branch',
         'Updated',
       ]);
@@ -108,66 +109,50 @@
           displayedLength + 1);
     });
 
-    test('_handleTargetTap', () => {
-      const checkbox = element.$$('table tr:nth-child(2) input');
-      let originalDisplayedColumns = element.displayedColumns;
-      const td = element.$$('table tr:nth-child(2) .checkboxContainer');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
-
-      MockInteractions.tap(checkbox);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
-
-      originalDisplayedColumns = element.displayedColumns;
-      MockInteractions.tap(td);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
+    test('_getDisplayedColumns', () => {
+      assert.deepEqual(element._getDisplayedColumns(), columns);
+      MockInteractions.tap(
+          element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.deepEqual(element._getDisplayedColumns(),
+          columns.filter(c => c !== 'Assignee'));
     });
 
-    test('_handleTargetTap on number', () => {
-      element.showNumber = false;
-      const checkbox = element.$$('table tr:nth-child(1) input');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
+    test('_handleCheckboxContainerTap relayes taps to checkboxes', () => {
+      sandbox.stub(element, '_handleNumberCheckboxTap');
+      sandbox.stub(element, '_handleTargetTap');
 
-      MockInteractions.tap(checkbox);
-      assert.isFalse(displayedColumnStub.called);
+      MockInteractions.tap(
+          element.$$('table tr:first-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isFalse(element._handleTargetTap.called);
+
+      MockInteractions.tap(
+          element.$$('table tr:last-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isTrue(element._handleTargetTap.calledOnce);
+    });
+
+    test('_handleNumberCheckboxTap', () => {
+      sandbox.spy(element, '_handleNumberCheckboxTap');
+
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
       assert.isTrue(element.showNumber);
 
-      MockInteractions.tap(checkbox);
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledTwice);
       assert.isFalse(element.showNumber);
     });
 
-    test('_updateDisplayedColumns', () => {
-      let name = 'Subject';
-      let checked = false;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Status',
-            'Owner',
-            'Assignee',
-            'Project',
-            'Branch',
-            'Updated',
-          ]);
-      name = 'Size';
-      checked = true;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Project',
-            'Branch',
-            'Updated',
-            'Size',
-          ]);
+    test('_handleTargetTap', () => {
+      sandbox.spy(element, '_handleTargetTap');
+      assert.include(element.displayedColumns, 'Assignee');
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.isTrue(element._handleTargetTap.calledOnce);
+      assert.notInclude(element.displayedColumns, 'Assignee');
     });
   });
 </script>
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 14e5e6f..029ce88 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
@@ -227,6 +227,16 @@
             </span>
           </section>
           <section>
+            <span class="title">Set new changes to "work in progress" by default</span>
+            <span class="value">
+              <input
+                  id="workInProgressByDefault"
+                  type="checkbox"
+                  checked$="[[_localPrefs.work_in_progress_by_default]]"
+                  on-change="_handleWorkInProgressByDefault">
+            </span>
+          </section>
+          <section>
             <span class="title">
               Insert Signed-off-by Footer For Inline Edit Changes
             </span>
@@ -338,6 +348,20 @@
                   on-change="_handleDiffSyntaxHighlightingChanged">
             </span>
           </section>
+          <section>
+          <div class="pref">
+            <span class="title">Ignore Whitespace</span>
+            <span class="value">
+              <gr-select bind-value="{{_diffPrefs.ignore_whitespace}}">
+                <select>
+                  <option value="IGNORE_NONE">None</option>
+                  <option value="IGNORE_TRAILING">Trailing</option>
+                  <option value="IGNORE_LEADING_AND_TRAILING">Leading & trailing</option>
+                  <option value="IGNORE_ALL">All</option>
+                </select>
+              </gr-select>
+            </span>
+          </div>
           <gr-button
               id="saveDiffPrefs"
               on-tap="_handleSaveDiffPreferences"
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 213ab65..0ce8ce0 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
@@ -24,6 +24,7 @@
     'email_strategy',
     'diff_view',
     'publish_comments_on_push',
+    'work_in_progress_by_default',
     'signed_off_by',
     'email_format',
     'size_bar_in_change_table',
@@ -248,7 +249,7 @@
     },
 
     _cloneChangeTableColumns() {
-      let columns = this.prefs.change_table;
+      let columns = this.getVisibleColumns(this.prefs.change_table);
 
       if (columns.length === 0) {
         columns = this.columnNames;
@@ -291,6 +292,11 @@
           this.$.publishCommentsOnPush.checked);
     },
 
+    _handleWorkInProgressByDefault() {
+      this.set('_localPrefs.work_in_progress_by_default',
+          this.$.workInProgressByDefault.checked);
+    },
+
     _handleInsertSignedOff() {
       this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.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 b208ba2..f47816f 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
@@ -175,6 +175,9 @@
       assert.equal(valueOf('Publish comments on push', 'preferences')
           .firstElementChild.checked, false);
       assert.equal(valueOf(
+          'Set new changes to "work in progress" by default', 'preferences')
+          .firstElementChild.checked, false);
+      assert.equal(valueOf(
           'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
           .firstElementChild.checked, false);
 
@@ -234,6 +237,30 @@
       });
     });
 
+    test('set new changes work-in-progress', done => {
+      const newChangesWorkInProgress =
+        valueOf('Set new changes to "work in progress" by default',
+            'preferences').firstElementChild;
+      MockInteractions.tap(newChangesWorkInProgress);
+
+      assert.isFalse(element._menuChanged);
+      assert.isTrue(element._prefsChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences(prefs) {
+          assert.equal(prefs.work_in_progress_by_default, true);
+          return Promise.resolve();
+        },
+      });
+
+      // Save the change.
+      element._handleSavePreferences().then(() => {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
+        done();
+      });
+    });
+
     test('diff preferences', done => {
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Context', 'diffPreferences')
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 52649db..7846b7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -49,7 +49,7 @@
       <table id="watchedProjects">
         <thead>
           <tr>
-            <th class="projectHeader">Project</th>
+            <th>Repo</th>
             <template is="dom-repeat" items="[[_getTypes()]]">
               <th class="notifType">[[item.name]]</th>
             </template>
@@ -98,7 +98,7 @@
                   id="newProject"
                   query="[[_query]]"
                   threshold="1"
-                  placeholder="Project"></gr-autocomplete>
+                  placeholder="Repo"></gr-autocomplete>
             </th>
             <th colspan$="[[_getTypeCount()]]">
               <input
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 89ab8fe..543ed85 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
@@ -70,6 +70,7 @@
       .transparentBackground,
       gr-button.transparentBackground {
         background-color: transparent;
+        padding: 0;
       }
       :host([disabled]) {
         opacity: .6;
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 7fb4cab..bdf37bf 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
@@ -64,7 +64,7 @@
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text limit="20" text="[[account.status]]"></gr-limited-text>)
+          (<gr-limited-text limit="10" text="[[account.status]]"></gr-limited-text>)
         </template>
       </span>
     </span>
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 8fff850..6564abe 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -30,8 +30,6 @@
         --background-color: var(--button-background-color, var(--default-button-background-color));
         --text-color: var(--default-button-text-color);
         display: inline-block;
-        font-family: var(--font-family-bold);
-        font-size: var(--font-size-small);
         position: relative;
       }
       :host([hidden]) {
@@ -52,7 +50,7 @@
         justify-content: center;
         margin: var(--margin, 0);
         min-width: var(--border, 0);
-        padding: var(--padding, 5px 10px);
+        padding: var(--padding, 4px 8px);
         @apply --gr-button;
       }
       paper-button:hover {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index 70b2635..a14c652 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -17,7 +17,6 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-star">
@@ -36,7 +35,6 @@
           class$="[[_computeStarClass(change.starred)]]"
           icon$="[[_computeStarIcon(change.starred)]]"></iron-icon>
     </button>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-star.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 9646735..3c46d1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -20,14 +20,18 @@
   Polymer({
     is: 'gr-change-star',
 
+    /**
+     * Fired when star state is toggled.
+     *
+     * @event toggle-star
+     */
+
     properties: {
       /** @type {?} */
       change: {
         type: Object,
         notify: true,
       },
-
-      _xhrPromise: Object, // Used for testing.
     },
 
     _computeStarClass(starred) {
@@ -42,8 +46,10 @@
     toggleStar() {
       const newVal = !this.change.starred;
       this.set('change.starred', newVal);
-      this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
-          newVal);
+      this.dispatchEvent(new CustomEvent('toggle-star', {
+        bubbles: true,
+        detail: {change: this.change, starred: newVal},
+      }));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index f24b45c..0ca9368 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -37,9 +37,6 @@
     let element;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        saveChangeStarred() { return Promise.resolve({ok: true}); },
-      });
       element = fixture('basic');
       element.change = {
         _number: 2,
@@ -60,23 +57,21 @@
     });
 
     test('starring', done => {
-      element.set('change.starred', false);
-      MockInteractions.tap(element.$$('button'));
-
-      element._xhrPromise.then(req => {
+      element.addEventListener('toggle-star', () => {
         assert.equal(element.change.starred, true);
         done();
       });
+      element.set('change.starred', false);
+      MockInteractions.tap(element.$$('button'));
     });
 
     test('unstarring', done => {
-      element.set('change.starred', true);
-      MockInteractions.tap(element.$$('button'));
-
-      element._xhrPromise.then(req => {
+      element.addEventListener('toggle-star', () => {
         assert.equal(element.change.starred, false);
         done();
       });
+      element.set('change.starred', true);
+      MockInteractions.tap(element.$$('button'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
index d1d9b33..32ca557 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
@@ -20,7 +20,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-copy-clipboard">
   <template>
@@ -30,28 +29,24 @@
         display: flex;
         flex-wrap: wrap;
       }
-      .text label {
-        flex: 0 0 100%;
-      }
       .copyText {
         flex-grow: 1;
         margin-right: .3em;
       }
-      .hideInput,
-      .hideLabel label {
+      .hideInput {
         display: none;
       }
       input {
         font-family: var(--monospace-font-family);
         font-size: inherit;
+        @apply --text-container-style;
       }
       #icon {
         height: 1.2em;
         width: 1.2em;
       }
     </style>
-    <div class$="text [[_computeLabelClass(hideLabel)]]">
-        <label>[[title]]</label>
+    <div class="text">
         <input id="input" is="iron-input"
             class$="copyText [[_computeInputClass(hideInput)]]"
             type="text"
@@ -66,8 +61,7 @@
             on-tap="_copyToClipboard">
           <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
         </gr-button>
-      </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    </div>
   </template>
   <script src="gr-copy-clipboard.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index cd8cb00..cabee36 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -24,7 +24,6 @@
 
     properties: {
       text: String,
-      title: String,
       buttonTitle: String,
       hasTooltip: {
         type: Boolean,
@@ -34,10 +33,6 @@
         type: Boolean,
         value: false,
       },
-      hideLabel: {
-        type: Boolean,
-        value: false,
-      },
     },
 
     focusOnCopy() {
@@ -48,10 +43,6 @@
       return hideInput ? 'hideInput' : '';
     },
 
-    _computeLabelClass(hideLabel) {
-      return hideLabel ? 'hideLabel' : '';
-    },
-
     _handleInputTap(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index c865917..d6e9dca 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -38,12 +38,8 @@
     let sandbox;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        saveChangeStarred() { return Promise.resolve({ok: true}); },
-      });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.title = 'Checkout';
       element.text = `git fetch http://gerrit@localhost:8080/a/test-project
           refs/changes/05/5/1 && git checkout FETCH_HEAD`;
       flushAsynchronousOperations();
@@ -79,12 +75,5 @@
       flushAsynchronousOperations();
       assert.equal(getComputedStyle(element.$.input).display, 'none');
     });
-
-    test('hideLabel', () => {
-      assert.notEqual(getComputedStyle(element.$$('label')).display, 'none');
-      element.hideLabel = true;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$$('label')).display, 'none');
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
similarity index 87%
rename from polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
rename to polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
index 92a7d5d..ac0a829 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
@@ -19,7 +19,7 @@
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
-<dom-module id="gr-confirm-dialog">
+<dom-module id="gr-dialog">
   <template>
     <style include="shared-styles">
       :host {
@@ -55,17 +55,22 @@
         flex-shrink: 0;
         justify-content: flex-end;
       }
+      .hidden {
+        display: none;
+      }
     </style>
     <div class="container" on-keydown="_handleKeydown">
       <header><slot name="header"></slot></header>
       <main><slot name="main"></slot></main>
       <footer>
-        <gr-button link on-tap="_handleCancelTap">[[cancelLabel]]</gr-button>
+        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-tap="_handleCancelTap">
+          [[cancelLabel]]
+        </gr-button>
         <gr-button id="confirm" link primary on-tap="_handleConfirm" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
       </footer>
     </div>
   </template>
-  <script src="gr-confirm-dialog.js"></script>
+  <script src="gr-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
similarity index 89%
rename from polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
rename to polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
index b8d137b..6163b09 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -18,7 +18,7 @@
   'use strict';
 
   Polymer({
-    is: 'gr-confirm-dialog',
+    is: 'gr-dialog',
 
     /**
      * Fired when the confirm button is pressed.
@@ -37,6 +37,7 @@
         type: String,
         value: 'Confirm',
       },
+      // Supplying an empty cancel label will hide the button completely.
       cancelLabel: {
         type: String,
         value: 'Cancel',
@@ -74,5 +75,9 @@
     resetFocus() {
       this.$.confirm.focus();
     },
+
+    _computeCancelClass(cancelLabel) {
+      return cancelLabel.length ? '' : 'hidden';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
similarity index 80%
rename from polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
rename to polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
index 8300dd6..4a5a181 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
@@ -17,23 +17,23 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-confirm-dialog</title>
+<title>gr-dialog</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="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-dialog.html">
+<link rel="import" href="gr-dialog.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
-    <gr-confirm-dialog></gr-confirm-dialog>
+    <gr-dialog></gr-dialog>
   </template>
 </test-fixture>
 
 <script>
-  suite('gr-confirm-dialog tests', () => {
+  suite('gr-dialog tests', () => {
     let element;
     let sandbox;
 
@@ -73,5 +73,19 @@
 
       assert.isTrue(handleConfirmStub.called);
     });
+
+    test('resetFocus', () => {
+      const focusStub = sandbox.stub(element.$.confirm, 'focus');
+      element.resetFocus();
+      assert.isTrue(focusStub.calledOnce);
+    });
+
+    test('empty cancel label hides cancel btn', () => {
+      assert.isFalse(isHidden(element.$.cancel));
+      element.cancelLabel = '';
+      flushAsynchronousOperations();
+
+      assert.isTrue(isHidden(element.$.cancel));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 7570533..bfa7885 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -15,26 +15,27 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+
+
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-download-commands">
   <template>
     <style include="shared-styles">
-      ul {
-        list-style: none;
+      paper-tabs {
+        height: 3rem;
         margin-bottom: .5em;
+        --paper-tabs-selection-bar-color: var(--link-color);
       }
-      li {
-        display: inline-block;
-        margin: 0;
-        padding: 0;
-      }
-      li gr-button {
-        margin-right: 1em;
+      paper-tab {
+        max-width: 15rem;
+        text-transform: uppercase;
+        --paper-tab-ink: var(--link-color);
       }
       label,
       input {
@@ -43,11 +44,6 @@
       label {
         font-family: var(--font-family-bold);
       }
-      li[selected] gr-button {
-        color: var(--primary-text-color);
-        font-family: var(--font-family-bold);
-        text-decoration: none;
-      }
       .schemes {
         display: flex;
         justify-content: space-between;
@@ -55,33 +51,33 @@
       .commands {
         display: flex;
         flex-direction: column;
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        padding: .5em;
       }
-      gr-copy-clipboard {
+      gr-shell-command {
         width: 60em;
         margin-bottom: .5em;
       }
+      .hidden {
+        display: none;
+      }
     </style>
     <div class="schemes">
-      <ul hidden$="[[!schemes.length]]" hidden>
+      <paper-tabs
+          id="downloadTabs"
+          class$="[[_computeShowTabs(schemes)]]"
+          selected="[[_computeSelected(schemes, selectedScheme)]]"
+          on-selected-changed="_handleTabChange">
         <template is="dom-repeat" items="[[schemes]]" as="scheme">
-          <li selected$="[[_computeSelected(scheme, selectedScheme)]]">
-            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
-              [[scheme]]
-            </gr-button>
-          </li>
+          <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
         </template>
-      </ul>
+      </paper-tabs>
     </div>
     <div class="commands" hidden$="[[!schemes.length]]" hidden>
       <template is="dom-repeat"
           items="[[commands]]"
           as="command">
-        <gr-copy-clipboard
-            title=[[command.title]]
-            text=[[command.command]]></gr-copy-clipboard>
+        <gr-shell-command
+            label=[[command.title]]
+            command=[[command.command]]></gr-shell-command>
       </template>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index 8f513cb..ca77a30 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -44,7 +44,7 @@
     },
 
     focusOnCopy() {
-      this.$$('gr-copy-clipboard').focusOnCopy();
+      this.$$('gr-shell-command').focusOnCopy();
     },
 
     _getLoggedIn() {
@@ -61,17 +61,24 @@
       });
     },
 
-    _computeSelected(item, selectedItem) {
-      return item === selectedItem;
+    _handleTabChange(e) {
+      const scheme = this.schemes[e.detail.value];
+      if (scheme && scheme !== this.selectedScheme) {
+        this.set('selectedScheme', scheme);
+        if (this._loggedIn) {
+          this.$.restAPI.savePreferences(
+              {download_scheme: this.selectedScheme});
+        }
+      }
     },
 
-    _handleSchemeTap(e) {
-      e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
-      this.selectedScheme = el.getAttribute('data-scheme');
-      if (this._loggedIn) {
-        this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
-      }
+    _computeSelected(schemes, selectedScheme) {
+      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0)
+          + '';
+    },
+
+    _computeShowTabs(schemes) {
+      return schemes.length > 1 ? '' : 'hidden';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index 4fe5569..c59e56a 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -74,37 +74,28 @@
       });
 
       test('focusOnCopy', () => {
-        const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+        const focusStub = sandbox.stub(element.$$('gr-shell-command'),
             'focusOnCopy');
         element.focusOnCopy();
         assert.isTrue(focusStub.called);
       });
 
       test('element visibility', () => {
-        assert.isFalse(element.$$('ul').hasAttribute('hidden'));
-        assert.isFalse(element.$$('.commands').hasAttribute('hidden'));
+        assert.isFalse(isHidden(element.$$('paper-tabs')));
+        assert.isFalse(isHidden(element.$$('.commands')));
 
         element.schemes = [];
-        assert.isTrue(element.$$('ul').hasAttribute('hidden'));
-        assert.isTrue(element.$$('.commands').hasAttribute('hidden'));
+        assert.isTrue(isHidden(element.$$('paper-tabs')));
+        assert.isTrue(isHidden(element.$$('.commands')));
       });
 
       test('tab selection', () => {
-        flushAsynchronousOperations();
-        let el = element.$$('[data-scheme="http"]').parentElement;
-        assert.isTrue(el.hasAttribute('selected'));
-        for (const scheme of ['repo', 'ssh']) {
-          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-          assert.isFalse(el.hasAttribute('selected'));
-        }
-
+        assert.equal(element.$.downloadTabs.selected, '0');
         MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
-        el = element.$$('[data-scheme="ssh"]').parentElement;
-        assert.isTrue(el.hasAttribute('selected'));
-        for (const scheme of ['http', 'repo']) {
-          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-          assert.isFalse(el.hasAttribute('selected'));
-        }
+        flushAsynchronousOperations();
+
+        assert.equal(element.selectedScheme, 'ssh');
+        assert.equal(element.$.downloadTabs.selected, '2');
       });
 
       test('loads scheme from preferences', done => {
@@ -136,18 +127,18 @@
 
       test('saves scheme to preferences', () => {
         element._loggedIn = true;
-        const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+        const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
             () => { return Promise.resolve(); });
 
         flushAsynchronousOperations();
 
-        const firstSchemeButton = element.$$('li gr-button[data-scheme]');
+        const repoTab = element.$$('paper-tab[data-scheme="repo"]');
 
-        MockInteractions.tap(firstSchemeButton);
+        MockInteractions.tap(repoTab);
 
         assert.isTrue(savePrefsStub.called);
         assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-            firstSchemeButton.getAttribute('data-scheme'));
+            repoTab.getAttribute('data-scheme'));
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 3abe28b..f4b120a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -78,7 +78,6 @@
       }
       .bottomContent {
         color: var(--deemphasized-text-color);
-        font-size: var(--font-size-small);
         /*
          * Should be 16px when the base font size is 13px (browser default of
          * 16px.
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index f943b8e..b7d65d3 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -92,7 +92,7 @@
 
     _showDropdown() {
       if (this.readOnly || this.editing) { return; }
-      this._open().then(() => {
+      return this._open().then(() => {
         this.$.input.$.input.focus();
         if (!this.$.input.value) { return; }
         this.$.input.$.input.setSelectionRange(0, this.$.input.value.length);
@@ -118,7 +118,7 @@
       let iters = 0;
       const step = () => {
         this.async(() => {
-          if (this.style.display !== 'none') {
+          if (this.$.dropdown.style.display !== 'none') {
             fn.call(this);
           } else if (iters++ < AWAIT_MAX_ITERS) {
             step.call(this);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 058654b..6815173 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -77,14 +77,17 @@
       assert.isFalse(element.$.dropdown.opened);
       assert.isTrue(label.classList.contains('editable'));
       assert.equal(label.textContent, 'value text');
+      const focusSpy = sandbox.spy(element.$.input.$.input, 'focus');
+      const showSpy = sandbox.spy(element, '_showDropdown');
 
       MockInteractions.tap(label);
 
-      Polymer.dom.flush();
-
-      // The dropdown is open (which covers up the label):
-      assert.isTrue(element.$.dropdown.opened);
-      assert.equal(input.value, 'value text');
+      return showSpy.lastCall.returnValue.then(() => {
+        // The dropdown is open (which covers up the label):
+        assert.isTrue(element.$.dropdown.opened);
+        assert.isTrue(focusSpy.called);
+        assert.equal(input.value, 'value text');
+      });
     });
 
     test('title with placeholder', done => {
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 9102bdb..34ca0ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -46,6 +46,8 @@
       <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
       <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
+      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
       <!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index cb8409e..47281c2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -34,7 +34,7 @@
    * Register a function to call to apply annotations. Plugins should use
    * GrAnnotationActionsContext.annotateRange to apply a CSS class to a range
    * within a line.
-   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
    *     that will be called when the AnnotationLayer is ready to annotate.
    */
   GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
@@ -45,7 +45,7 @@
   /**
    * The specified function will be called with a notify function for the plugin
    * to call when it has all required data for annotation. Optional.
-   * @param {Function<Function<String, Number, Number, String>>} notifyFunc See
+   * @param {function(function(String, Number, Number, String))} notifyFunc See
    *     doc of the notify function below to see what it does.
    */
   GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
@@ -68,7 +68,7 @@
    *
    * @param {String} checkboxLabel Will be used as the label for the checkbox.
    *     Optional. "Enable" is used if this is not specified.
-   * @param {Function<HTMLElement>} onAttached The function that will be called
+   * @param {function(HTMLElement)} onAttached The function that will be called
    *     when the checkbox is attached to the page.
    */
   GrAnnotationActionsInterface.prototype.enableToggleCheckbox = function(
@@ -134,7 +134,7 @@
    * @param {String} path The file path (eg: /COMMIT_MSG').
    * @param {String} changeNum The Gerrit change number.
    * @param {String} patchNum The Gerrit patch number.
-   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
    *     that will be called when the AnnotationLayer is ready to annotate.
    */
   function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
@@ -148,7 +148,7 @@
 
   /**
    * Register a listener for layer updates.
-   * @param {Function<Number, Number, String>} fn The update handler function.
+   * @param {function(Number, Number, String)} fn The update handler function.
    *     Should accept as arguments the line numbers for the start and end of
    *     the update and the side as a string.
    */
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 105e543..407a6a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -114,6 +114,11 @@
     this._el.setActionButtonProp(key, 'label', text);
   };
 
+  GrChangeActionsInterface.prototype.setTitle = function(key, text) {
+    ensureEl(this);
+    this._el.setActionButtonProp(key, 'title', text);
+  };
+
   GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
     ensureEl(this);
     this._el.setActionButtonProp(key, 'enabled', enabled);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 988ed96..fef4fc9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -143,10 +143,12 @@
           assert.equal(button.getAttribute('data-label'), 'Bork!');
           assert.isNotOk(button.disabled);
           changeActions.setLabel(key, 'Yo');
+          changeActions.setTitle(key, 'Yo hint');
           changeActions.setEnabled(key, false);
           changeActions.setIcon(key, 'pupper');
           flush(() => {
             assert.equal(button.getAttribute('data-label'), 'Yo');
+            assert.equal(button.getAttribute('title'), 'Yo hint');
             assert.isTrue(button.disabled);
             assert.equal(Polymer.dom(button).querySelector('iron-icon').icon,
                 'gr-icons:pupper');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index bd5e2a2..e0c7c37 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -78,6 +78,16 @@
       assert.strictEqual(plugin, otherPlugin);
     });
 
+    test('flushes preinstalls if provided', () => {
+      assert.doesNotThrow(() => {
+        Gerrit._flushPreinstalls();
+      });
+      window.Gerrit.flushPreinstalls = sandbox.stub();
+      Gerrit._flushPreinstalls();
+      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+      delete window.Gerrit.flushPreinstalls;
+    });
+
     test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
@@ -412,6 +422,33 @@
           element.EventType.ADMIN_MENU_LINKS);
     });
 
+    test('Gerrit._isPluginPreloaded', () => {
+      Gerrit._preloadedPlugins = {baz: ()=>{}};
+      assert.isFalse(Gerrit._isPluginPreloaded('plugins/foo/bar'));
+      assert.isFalse(Gerrit._isPluginPreloaded('http://a.com/42'));
+      assert.isTrue(Gerrit._isPluginPreloaded('preloaded:baz'));
+      Gerrit._preloadedPlugins = null;
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sandbox.stub();
+      Gerrit._preloadedPlugins = {foo: installStub};
+      Gerrit._installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      window.ASSETS_PATH = 'http://blips.com/chitz/';
+      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          'http://blips.com/plugins/foo/some/thing.html');
+      delete window.ASSETS_PATH;
+    });
+
     suite('test plugin with base url', () => {
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
index 57cbc85..c18f753 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -43,10 +43,14 @@
    * @param {string} method HTTP Method (GET, POST, etc)
    * @param {string} url URL without base path or plugin prefix
    * @param {Object=} payload Respected for POST and PUT only.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
    * @return {!Promise}
    */
-  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload) {
-    return getRestApi().send(method, this.opt_prefix + url, opt_payload);
+  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
+      opt_errFn) {
+    return getRestApi().send(method, this.opt_prefix + url, opt_payload,
+        opt_errFn);
   };
 
   /**
@@ -54,10 +58,13 @@
    * @param {string} method HTTP Method (GET, POST, etc)
    * @param {string} url URL without base path or plugin prefix
    * @param {Object=} payload Respected for POST and PUT only.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
    * @return {!Promise} resolves on success, rejects on error.
    */
-  GrPluginRestApi.prototype.send = function(method, url, opt_payload) {
-    return this.fetch(method, url, opt_payload).then(response => {
+  GrPluginRestApi.prototype.send = function(method, url, opt_payload,
+      opt_errFn) {
+    return this.fetch(method, url, opt_payload, opt_errFn).then(response => {
       if (response.status < 200 || response.status >= 300) {
         return response.text().then(text => {
           if (text) {
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 3719877..36a428d 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
@@ -31,6 +31,8 @@
 
   let _pluginsPendingCount = -1;
 
+  const PRELOADED_PROTOCOL = 'preloaded:';
+
   const UNKNOWN_PLUGIN = 'unknown';
 
   const PANEL_ENDPOINTS_MAPPING = {
@@ -101,6 +103,21 @@
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function flushPreinstalls() {
+    if (window.Gerrit.flushPreinstalls) {
+      window.Gerrit.flushPreinstalls();
+    }
+  }
+
+  function installPreloadedPlugins() {
+    if (!Gerrit._preloadedPlugins) { return; }
+    for (const name in Gerrit._preloadedPlugins) {
+      if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
+      const callback = Gerrit._preloadedPlugins[name];
+      Gerrit.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+    }
+  }
+
   function getPluginNameFromUrl(url) {
     if (!(url instanceof URL)) {
       try {
@@ -110,6 +127,9 @@
         return null;
       }
     }
+    if (url.protocol === PRELOADED_PROTOCOL) {
+      return url.pathname;
+    }
     const base = Gerrit.BaseUrlBehavior.getBaseUrl();
     const pathname = url.pathname.replace(base, '');
     // Site theme is server from predefined path.
@@ -147,6 +167,13 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
+    if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Original plugin URL is used in plugin assets URLs calculation.
+      const assetsBaseUrl = window.ASSETS_PATH ||
+          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
+      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
+          '/static/' + this._name + '.js');
+    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -421,6 +448,8 @@
     },
   };
 
+  flushPreinstalls();
+
   const Gerrit = window.Gerrit || {};
 
   let _resolveAllPluginsLoaded = null;
@@ -432,6 +461,8 @@
   const app = document.querySelector('#app');
   if (!app) {
     // No gr-app found (running tests)
+    Gerrit._installPreloadedPlugins = installPreloadedPlugins;
+    Gerrit._flushPreinstalls = flushPreinstalls;
     Gerrit._resetPlugins = () => {
       _allPluginsPromise = null;
       _pluginsInstalled = [];
@@ -622,5 +653,18 @@
     return `${pluginName}-screen-${screenName}`;
   };
 
+  Gerrit._isPluginPreloaded = function(url) {
+    const name = getPluginNameFromUrl(url);
+    if (name && Gerrit._preloadedPlugins) {
+      return name in Gerrit._preloadedPlugins;
+    } else {
+      return false;
+    }
+  };
+
   window.Gerrit = Gerrit;
+
+  // Preloaded plugins should be installed after Gerrit.install() is set,
+  // since plugin preloader substitutes Gerrit.install() temporarily.
+  installPreloadedPlugins();
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
new file mode 100644
index 0000000..ca5c49f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -0,0 +1,127 @@
+<!--
+@license
+Copyright (C) 2018 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="../../../styles/gr-voting-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../gr-account-label/gr-account-label.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-icons/gr-icons.html">
+<link rel="import" href="../gr-label/gr-label.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-label-info">
+  <template strip-whitespace>
+    <style include="gr-voting-styles"></style>
+    <style include="shared-styles">
+      .placeholder {
+        color: var(--deemphasized-text-color);
+        padding-top: .2em;
+      }
+      .hidden {
+        display: none;
+      }
+      .voteChip {
+        display: flex;
+        justify-content: center;
+        margin-right: .3em;
+        padding: .05em .85em;
+        @apply --vote-chip-styles;
+      }
+      .max {
+        background-color: var(--vote-color-approved);
+      }
+      .min {
+        background-color: var(--vote-color-rejected);
+      }
+      .positive {
+        background-color: var(--vote-color-recommended);
+      }
+      .negative {
+        background-color: var(--vote-color-disliked);
+      }
+      .hidden {
+        display: none;
+      }
+      td {
+        vertical-align: middle;
+      }
+      tr {
+        min-height: 2.25em;
+      }
+      gr-button {
+        --gr-button: {
+          height: 2em;
+          padding: 0;
+          width: 2em;
+        }
+      }
+      gr-button[disabled] iron-icon {
+        color: var(--border-color);
+      }
+      gr-account-chip {
+        margin-right: .25em;
+      }
+      iron-icon {
+        height: 1.2em;
+        width: 1.2em;
+      }
+      .labelValueContainer:not(:first-of-type) td {
+        padding-top: .3em;
+      }
+    </style>
+    <table>
+      <p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
+        No votes.
+      </p>
+      <template
+          is="dom-repeat"
+          items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
+          as="mappedLabel">
+        <tr class="labelValueContainer">
+          <td>
+            <gr-label
+                has-tooltip
+                title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
+                class$="[[mappedLabel.className]] voteChip">
+              [[mappedLabel.value]]
+            </gr-label>
+          </td>
+          <td>
+            <gr-account-chip
+                account="[[mappedLabel.account]]"
+                transparent-background></gr-account-chip>
+          </td>
+          <td>
+            <gr-button
+                link
+                aria-label="Remove"
+                on-tap="_onDeleteVote"
+                tooltip="Remove vote"
+                data-account-id$="[[mappedLabel.account._account_id]]"
+                class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
+              <iron-icon icon="gr-icons:delete"></iron-icon>
+            </gr-button>
+          </td>
+        </tr>
+      </template>
+    </table>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-label-info.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
new file mode 100644
index 0000000..2fe5f7b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-label-info',
+
+    properties: {
+      labelInfo: Object,
+      label: String,
+      /** @type {?} */
+      change: Object,
+      account: Object,
+      mutable: Boolean,
+    },
+
+    /**
+     * @param {!Object} labelInfo
+     * @param {!Object} account
+     * @param {Object} changeLabelsRecord not used, but added as a parameter in
+     *    order to trigger computation when a label is removed from the change.
+     */
+    _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
+      const result = [];
+      if (!labelInfo) { return result; }
+      if (!labelInfo.values) {
+        if (labelInfo.rejected || labelInfo.approved) {
+          const ok = labelInfo.approved || !labelInfo.rejected;
+          return [{
+            value: ok ? '👍️' : '👎️',
+            className: ok ? 'positive' : 'negative',
+            account: ok ? labelInfo.approved : labelInfo.rejected,
+          }];
+        }
+        return result;
+      }
+      // Sort votes by positivity.
+      const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
+      const values = Object.keys(labelInfo.values);
+      for (const label of votes) {
+        if (label.value && label.value != labelInfo.default_value) {
+          let labelClassName;
+          let labelValPrefix = '';
+          if (label.value > 0) {
+            labelValPrefix = '+';
+            if (parseInt(label.value, 10) ===
+                parseInt(values[values.length - 1], 10)) {
+              labelClassName = 'max';
+            } else {
+              labelClassName = 'positive';
+            }
+          } else if (label.value < 0) {
+            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
+              labelClassName = 'min';
+            } else {
+              labelClassName = 'negative';
+            }
+          }
+          const formattedLabel = {
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          };
+          if (label._account_id === account._account_id) {
+            // Put self-votes at the top.
+            result.unshift(formattedLabel);
+          } else {
+            result.push(formattedLabel);
+          }
+        }
+      }
+      return result;
+    },
+
+    /**
+     * A user is able to delete a vote iff the mutable property is true and the
+     * reviewer that left the vote exists in the list of removable_reviewers
+     * received from the backend.
+     *
+     * @param {!Object} reviewer An object describing the reviewer that left the
+     *     vote.
+     * @param {Boolean} mutable
+     * @param {!Object} change
+     */
+    _computeDeleteClass(reviewer, mutable, change) {
+      if (!mutable || !change || !change.removable_reviewers) {
+        return 'hidden';
+      }
+      const removable = change.removable_reviewers;
+      if (removable.find(r => r._account_id === reviewer._account_id)) {
+        return '';
+      }
+      return 'hidden';
+    },
+
+    /**
+     * Closure annotation for Polymer.prototype.splice is off.
+     * For now, supressing annotations.
+     *
+     * @suppress {checkTypes} */
+    _onDeleteVote(e) {
+      e.preventDefault();
+      let target = Polymer.dom(e).rootTarget;
+      while (!target.classList.contains('deleteBtn')) {
+        if (!target.parentElement) { return; }
+        target = target.parentElement;
+      }
+
+      target.disabled = true;
+      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this._xhrPromise =
+          this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
+          .then(response => {
+            target.disabled = false;
+            if (!response.ok) { return response; }
+
+            const label = this.change.labels[this.label];
+            const labels = label.all || [];
+            let wasChanged = false;
+            for (let i = 0; i < labels.length; i++) {
+              if (labels[i]._account_id === accountID) {
+                for (const key in label) {
+                  if (label.hasOwnProperty(key) &&
+                      label[key]._account_id === accountID) {
+                    // Remove special label field, keeping change label values
+                    // in sync with the backend.
+                    this.change.labels[this.label][key] = null;
+                  }
+                }
+                this.change.labels[this.label].all.splice(i, 1);
+                wasChanged = true;
+                break;
+              }
+            }
+            if (wasChanged) { this.notifySplices('change.labels'); }
+          }).catch(err => {
+            target.disabled = false;
+            return;
+          });
+    },
+
+    _computeValueTooltip(labelInfo, score) {
+      if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
+        return '';
+      }
+      return labelInfo.values[score];
+    },
+
+    /**
+     * @param {!Object} labelInfo
+     * @param {Object} changeLabelsRecord not used, but added as a parameter in
+     *    order to trigger computation when a label is removed from the change.
+     */
+    _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
+      if (labelInfo.all) {
+        for (const label of labelInfo.all) {
+          if (label.value && label.value != labelInfo.default_value) {
+            return 'hidden';
+          }
+        }
+      }
+      return '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
new file mode 100644
index 0000000..8bc358d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -0,0 +1,230 @@
+<!--
+@license
+Copyright (C) 2018 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-label-info</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-label-info.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-label-info></gr-label-info>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-link tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      // Needed to trigger computed bindings.
+      element.account = {};
+      element.change = {labels: {}};
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('remove reviewer votes', () => {
+      setup(() => {
+        sandbox.stub(element, '_computeValueTooltip').returns('');
+        element.account = {
+          _account_id: 1,
+          name: 'bojack',
+        };
+        const test = {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+        };
+        element.change = {
+          _number: 42,
+          change_id: 'the id',
+          actions: [],
+          topic: 'the topic',
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {test},
+          removable_reviewers: [],
+        };
+        element.labelInfo = test;
+        element.label = 'test';
+
+        flushAsynchronousOperations();
+      });
+
+      test('_computeCanDeleteVote', () => {
+        element.mutable = false;
+        const button = element.$$('gr-button');
+        assert.isTrue(isHidden(button));
+        element.change.removable_reviewers = [element.account];
+        element.mutable = true;
+        assert.isFalse(isHidden(button));
+      });
+
+      test('deletes votes', () => {
+        const deleteResponse = Promise.resolve({ok: true});
+        const deleteStub = sandbox.stub(
+            element.$.restAPI, 'deleteVote').returns(deleteResponse);
+
+        element.change.removable_reviewers = [element.account];
+        element.change.labels.test.recommended = {_account_id: 1};
+        element.mutable = true;
+        const button = element.$$('gr-button');
+        MockInteractions.tap(button);
+        assert.isTrue(button.disabled);
+        return deleteResponse.then(() => {
+          assert.isFalse(button.disabled);
+          assert.notOk(element.change.labels.test.recommended);
+          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
+        });
+      });
+    });
+
+    suite('label color and order', () => {
+      test('valueless label rejected', () => {
+        element.labelInfo = {rejected: {name: 'someone'}};
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('negative'));
+      });
+
+      test('valueless label approved', () => {
+        element.labelInfo = {approved: {name: 'someone'}};
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('positive'));
+      });
+
+      test('-2 to +2', () => {
+        element.labelInfo = {
+          all: [
+            {value: 2, name: 'user 2'},
+            {value: 1, name: 'user 1'},
+            {value: -1, name: 'user 3'},
+            {value: -2, name: 'user 4'},
+          ],
+          values: {
+            '-2': 'Awful',
+            '-1': 'Don\'t submit as-is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me',
+            '+2': 'Ready to submit',
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('max'));
+        assert.isTrue(labels[1].classList.contains('positive'));
+        assert.isTrue(labels[2].classList.contains('negative'));
+        assert.isTrue(labels[3].classList.contains('min'));
+      });
+
+      test('-1 to +1', () => {
+        element.labelInfo = {
+          all: [
+            {value: 1, name: 'user 1'},
+            {value: -1, name: 'user 2'},
+          ],
+          values: {
+            '-1': 'Don\'t submit as-is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me',
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('max'));
+        assert.isTrue(labels[1].classList.contains('min'));
+      });
+
+      test('0 to +2', () => {
+        element.labelInfo = {
+          all: [
+            {value: 1, name: 'user 2'},
+            {value: 2, name: 'user '},
+          ],
+          values: {
+            ' 0': 'Don\'t submit as-is',
+            '+1': 'No score',
+            '+2': 'Looks good to me',
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('max'));
+        assert.isTrue(labels[1].classList.contains('positive'));
+      });
+
+      test('self votes at top', () => {
+        element.account = {
+          _account_id: 1,
+          name: 'bojack',
+        };
+        element.labelInfo = {
+          all: [
+            {value: 1, name: 'user 1', _account_id: 2},
+            {value: -1, name: 'bojack', _account_id: 1},
+          ],
+          values: {
+            '-1': 'Don\'t submit as-is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me',
+          },
+        };
+        flushAsynchronousOperations();
+        const chips =
+            Polymer.dom(element.root).querySelectorAll('gr-account-chip');
+        assert.equal(chips[0].account._account_id, element.account._account_id);
+      });
+    });
+
+    test('_computeValueTooltip', () => {
+      // Existing label.
+      let labelInfo = {values: {0: 'Baz'}};
+      let score = '0';
+      assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+      // Non-exsistent score.
+      score = '2';
+      assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+      // No values on label.
+      labelInfo = {values: {}};
+      score = '0';
+      assert.equal(element._computeValueTooltip(labelInfo, score), '');
+    });
+
+    test('placeholder', () => {
+      element.labelInfo = {};
+      assert.isFalse(isHidden(element.$$('.placeholder')));
+      element.labelInfo = {all: []};
+      assert.isFalse(isHidden(element.$$('.placeholder')));
+      element.labelInfo = {all: [{value: 1}]};
+      assert.isTrue(isHidden(element.$$('.placeholder')));
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
new file mode 100644
index 0000000..5825301
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
@@ -0,0 +1,72 @@
+<!--
+@license
+Copyright (C) 2018 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="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-labeled-autocomplete">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        width: 12em;
+      }
+      #container {
+        background: var(--chip-background-color);
+        border-radius: 1em;
+        padding: .5em;
+      }
+      #header {
+        color: var(--deemphasized-text-color);
+        font-family: var(--font-family-bold);
+        font-size: var(--font-size-small);
+      }
+      #body {
+        display: flex;
+      }
+      gr-autocomplete {
+        height: 1.5em;
+        --gr-autocomplete: {
+          border: none;
+        }
+      }
+      #trigger {
+        border-left: 1px solid var(--deemphasized-text-color);
+        color: var(--deemphasized-text-color);
+        cursor: pointer;
+        padding-left: .4em;
+      }
+      #trigger:hover {
+        color: var(--primary-text-color);
+      }
+    </style>
+    <div id="container">
+      <div id="header">[[label]]</div>
+      <div id="body">
+        <gr-autocomplete
+            id="autocomplete"
+            threshold="[[_autocompleteThreshold]]"
+            query="[[query]]"
+            disabled="[[disabled]]"
+            placeholder="[[placeholder]]"
+            borderless></gr-autocomplete>
+        <div id="trigger" on-tap="_handleTriggerTap">▼</div>
+      </div>
+    </div>
+  </template>
+  <script src="gr-labeled-autocomplete.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
new file mode 100644
index 0000000..cb3d546
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-labeled-autocomplete',
+
+    /**
+     * Fired when a value is chosen.
+     *
+     * @event commit
+     */
+
+    properties: {
+
+      /**
+       * Used just like the query property of gr-autocomplete.
+       *
+       * @type {function(string): Promise<?>}
+       */
+      query: {
+        type: Function,
+        value() {
+          return function() {
+            return Promise.resolve([]);
+          };
+        },
+      },
+
+      text: {
+        type: String,
+        value: '',
+        notify: true,
+      },
+      label: String,
+      placeholder: String,
+      disabled: Boolean,
+
+      _autocompleteThreshold: {
+        type: Number,
+        value: 0,
+        readOnly: true,
+      },
+    },
+
+    _handleTriggerTap() {
+      this.$.autocomplete.focus();
+    },
+
+    setText(text) {
+      this.$.autocomplete.setText(text);
+    },
+
+    clear() {
+      this.setText('');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
new file mode 100644
index 0000000..f7632d3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-labeled-autocomplete</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-labeled-autocomplete.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-labeled-autocomplete></gr-labeled-autocomplete>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-labeled-autocomplete tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('tapping trigger focuses autocomplete', () => {
+      sandbox.stub(element.$.autocomplete, 'focus');
+      element._handleTriggerTap();
+      assert.isTrue(element.$.autocomplete.focus.calledOnce);
+    });
+
+    test('setText', () => {
+      sandbox.stub(element.$.autocomplete, 'setText');
+      element.setText('foo-bar');
+      assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
index ec589fe..c35768f 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -16,6 +16,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../bower_components/ba-linkify/ba-linkify.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 22e14e9..530da02 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -60,15 +60,16 @@
      *     commentLink patterns
      */
     _contentOrConfigChanged(content, config) {
-      var output = Polymer.dom(this.$.output);
+      config = Gerrit.Nav.mapCommentlinks(config);
+      const output = Polymer.dom(this.$.output);
       output.textContent = '';
-      var parser = new GrLinkTextParser(config,
+      const parser = new GrLinkTextParser(config,
           this._handleParseResult.bind(this), this.removeZeroWidthSpace);
       parser.parse(content);
 
       // Ensure that links originating from HTML commentlink configs open in a
       // new tab. @see Issue 5567
-      output.querySelectorAll('a').forEach(function(anchor) {
+      output.querySelectorAll('a').forEach(anchor => {
         anchor.setAttribute('target', '_blank');
         anchor.setAttribute('rel', 'noopener');
       });
@@ -87,9 +88,9 @@
      * @param  {DocumentFragment|undefined} fragment
      */
     _handleParseResult(text, href, fragment) {
-      var output = Polymer.dom(this.$.output);
+      const output = Polymer.dom(this.$.output);
       if (href) {
-        var a = document.createElement('a');
+        const a = document.createElement('a');
         a.href = href;
         a.textContent = text;
         a.target = '_blank';
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 baa025e..23c1442 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
@@ -37,29 +37,30 @@
 </test-fixture>
 
 <script>
-  suite('gr-linked-text tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
       element.config = {
         ph: {
           match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2'
+          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2',
         },
         changeid: {
           match: '(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         changeid2: {
           match: 'Change-Id: +(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         googlesearch: {
           match: 'google:(.+)',
-          link: 'https://bing.com/search?q=$1',  // html should supercede link.
+          link: 'https://bing.com/search?q=$1', // html should supercede link.
           html: '<a href="https://google.com/search?q=$1">$1</a>',
         },
         hashedhtml: {
@@ -74,27 +75,27 @@
       };
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('URL pattern was parsed and linked.', function() {
-      // Reguar inline link.
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+    test('URL pattern was parsed and linked.', () => {
+      // Regular inline link.
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       element.content = url;
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.rel, 'noopener');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, url);
     });
 
-    test('Bug pattern was parsed and linked', function() {
+    test('Bug pattern was parsed and linked', () => {
       // "Issue/Bug" pattern.
       element.content = 'Issue 3650';
 
-      var linkEl = element.$.output.childNodes[0];
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      let linkEl = element.$.output.childNodes[0];
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, 'Issue 3650');
@@ -107,26 +108,26 @@
       assert.equal(linkEl.textContent, 'Bug 3650');
     });
 
-    test('Change-Id pattern was parsed and linked', function() {
+    test('Change-Id pattern was parsed and linked', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
       element.content = prefix + changeID;
 
-      var textNode = element.$.output.childNodes[0];
-      var linkEl = element.$.output.childNodes[1];
+      const textNode = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[1];
       assert.equal(textNode.textContent, prefix);
-      var url = '/q/' + changeID;
+      const url = '/q/' + changeID;
       assert.equal(linkEl.target, '_blank');
       // Since url is a path, the host is added automatically.
       assert.isTrue(linkEl.href.endsWith(url));
       assert.equal(linkEl.textContent, changeID);
     });
 
-    test('Multiple matches', function() {
+    test('Multiple matches', () => {
       element.content = 'Issue 3650\nIssue 3450';
-      var linkEl1 = element.$.output.childNodes[0];
-      var linkEl2 = element.$.output.childNodes[2];
+      const linkEl1 = element.$.output.childNodes[0];
+      const linkEl2 = element.$.output.childNodes[2];
 
       assert.equal(linkEl1.target, '_blank');
       assert.equal(linkEl1.href,
@@ -139,22 +140,22 @@
       assert.equal(linkEl2.textContent, 'Issue 3450');
     });
 
-    test('Change-Id pattern parsed before bug pattern', function() {
+    test('Change-Id pattern parsed before bug pattern', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
 
       // "Issue/Bug" pattern.
-      var bug = 'Issue 3650';
+      const bug = 'Issue 3650';
 
-      var changeUrl = '/q/' + changeID;
-      var bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      const changeUrl = '/q/' + changeID;
+      const bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
 
       element.content = prefix + changeID + bug;
 
-      var textNode = element.$.output.childNodes[0];
-      var changeLinkEl = element.$.output.childNodes[1];
-      var bugLinkEl = element.$.output.childNodes[2];
+      const textNode = element.$.output.childNodes[0];
+      const changeLinkEl = element.$.output.childNodes[1];
+      const bugLinkEl = element.$.output.childNodes[2];
 
       assert.equal(textNode.textContent, prefix);
 
@@ -167,41 +168,41 @@
       assert.equal(bugLinkEl.textContent, 'Issue 3650');
     });
 
-    test('html field in link config', function() {
+    test('html field in link config', () => {
       element.content = 'google:do a barrel roll';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.getAttribute('href'),
           'https://google.com/search?q=do a barrel roll');
       assert.equal(linkEl.textContent, 'do a barrel roll');
     });
 
-    test('removing hash from links', function() {
+    test('removing hash from links', () => {
       element.content = 'hash:foo';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
       assert.equal(linkEl.textContent, 'foo');
     });
 
-    test('disabled config', function() {
+    test('disabled config', () => {
       element.content = 'foo:baz';
       assert.equal(element.$.output.innerHTML, 'foo:baz');
     });
 
-    test('R=email labels link correctly', function() {
+    test('R=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'R=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'R=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
     });
 
-    test('CC=email labels link correctly', function() {
+    test('CC=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'CC=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'CC=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
     });
 
-    test('only {http,https,mailto} protocols are linkified', function() {
+    test('only {http,https,mailto} protocols are linkified', () => {
       element.content = 'xx mailto:test@google.com yy';
       let links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
@@ -226,7 +227,7 @@
       assert.equal(links.length, 0);
     });
 
-    test('overlapping links', function() {
+    test('overlapping links', () => {
       element.config = {
         b1: {
           match: '(B:\\s*)(\\d+)',
@@ -238,7 +239,7 @@
         },
       };
       element.content = '- B: 123, 45';
-      var links = Polymer.dom(element.root).querySelectorAll('a');
+      const links = Polymer.dom(element.root).querySelectorAll('a');
 
       assert.equal(links.length, 2);
       assert.equal(element.$$('span').textContent, '- B: 123, 45');
@@ -250,31 +251,31 @@
       assert.equal(links[1].textContent, '45');
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isTrue(contentConfigStub.called);
     });
   });
 
-  suite('gr-linked-text with null config', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text with null config', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged not called without config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isFalse(contentConfigStub.called);
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 8b49ca0..8526c3e 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
@@ -41,8 +41,8 @@
    * @param {Object|null|undefined} linkConfig Comment links as specified by the
    *     commentlinks field on a project config.
    * @param {Function} callback The callback to be fired when an intermediate
-   *     parse result is emitted. The callback is passed text and href strings if
-   *     a link is to be created, or a document fragment otherwise.
+   *     parse result is emitted. The callback is passed text and href strings
+   *     if a link is to be created, or a document fragment otherwise.
    * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
    *     spaces will be removed from R=<email> and CC=<email> expressions.
    */
@@ -73,14 +73,14 @@
    */
   GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
     this.sortArrayReverse(outputArray);
-    var fragment = document.createDocumentFragment();
-    var cursor = text.length;
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
 
     // Start inserting linkified URLs from the end of the String. That way, the
     // string positions of the items don't change as we iterate through.
-    outputArray.forEach(function(item) {
-      // Add any text between the current linkified item and the item added before
-      // if it exists.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
       if (item.position + item.length !== cursor) {
         fragment.insertBefore(
             document.createTextNode(
@@ -130,32 +130,32 @@
    */
   GrLinkTextParser.prototype.addItem =
       function(text, href, html, position, length, outputArray) {
-    var htmlOutput = '';
+        let htmlOutput = '';
 
-    if (href) {
-      var a = document.createElement('a');
-      a.href = href;
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      htmlOutput = a;
-    } else if (html) {
-      var fragment = document.createDocumentFragment();
+        if (href) {
+          const a = document.createElement('a');
+          a.href = href;
+          a.textContent = text;
+          a.target = '_blank';
+          a.rel = 'noopener';
+          htmlOutput = a;
+        } else if (html) {
+          const fragment = document.createDocumentFragment();
       // Create temporary div to hold the nodes in.
-      var div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      htmlOutput = fragment;
-    }
+          const div = document.createElement('div');
+          div.innerHTML = html;
+          while (div.firstChild) {
+            fragment.appendChild(div.firstChild);
+          }
+          htmlOutput = fragment;
+        }
 
-    outputArray.push({
-      html: htmlOutput,
-      position: position,
-      length: length,
-    });
-  };
+        outputArray.push({
+          html: htmlOutput,
+          position,
+          length,
+        });
+      };
 
   /**
    * Create a CommentLinkItem for a link and append it to the given output
@@ -171,9 +171,9 @@
    */
   GrLinkTextParser.prototype.addLink =
       function(text, href, position, length, outputArray) {
-    if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(text, href, null, position, length, outputArray);
-  };
+        if (!text || this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(text, href, null, position, length, outputArray);
+      };
 
   /**
    * Create a CommentLinkItem specified by an HTMl string and append it to the
@@ -188,9 +188,9 @@
    */
   GrLinkTextParser.prototype.addHTML =
       function(html, position, length, outputArray) {
-    if (this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(null, null, html, position, length, outputArray);
-  };
+        if (this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(null, null, html, position, length, outputArray);
+      };
 
   /**
    * Does the given range overlap with anything already in the item list.
@@ -200,18 +200,18 @@
    */
   GrLinkTextParser.prototype.hasOverlap =
       function(position, length, outputArray) {
-    var endPosition = position + length;
-    for (var i = 0; i < outputArray.length; i++) {
-      var arrayItemStart = outputArray[i].position;
-      var arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if ((position >= arrayItemStart && position < arrayItemEnd) ||
+        const endPosition = position + length;
+        for (let i = 0; i < outputArray.length; i++) {
+          const arrayItemStart = outputArray[i].position;
+          const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+          if ((position >= arrayItemStart && position < arrayItemEnd) ||
         (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
         (position === arrayItemStart && position === arrayItemEnd)) {
             return true;
-      }
-    }
-    return false;
-  };
+          }
+        }
+        return false;
+      };
 
   /**
    * Parse the given source text and emit callbacks for the items that are
@@ -241,9 +241,9 @@
       text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
     }
 
-    // If the href is provided then ba-linkify has recognized it as a URL. If the
-    // source text does not include a protocol, the protocol will be added by
-    // ba-linkify. Create the link if the href is provided and its protocol
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
     if (href && URL_PROTOCOL_PATTERN.test(href)) {
       this.addText(text, href);
@@ -262,9 +262,10 @@
    *   object.
    */
   GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-    // The outputArray is used to store all of the matches found for all patterns.
-    var outputArray = [];
-    for (var p in patterns) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray = [];
+    for (const p in patterns) {
       if (patterns[p].enabled != null && patterns[p].enabled == false) {
         continue;
       }
@@ -279,38 +280,37 @@
         }
       }
 
-      var pattern = new RegExp(patterns[p].match, 'g');
+      const pattern = new RegExp(patterns[p].match, 'g');
 
-      var match;
-      var textToCheck = text;
-      var susbtrIndex = 0;
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
 
       while ((match = pattern.exec(textToCheck)) != null) {
         textToCheck = textToCheck.substr(match.index + match[0].length);
-        var result = match[0].replace(pattern,
+        let result = match[0].replace(pattern,
             patterns[p].html || patterns[p].link);
 
+        let i;
         // 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;
-          }
+        for (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 + i,
-            match[0].length - i,
-            outputArray);
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else if (patterns[p].link) {
           this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              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-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
index 7df77ca..be02d40 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -17,7 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
new file mode 100644
index 0000000..d794dd6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
@@ -0,0 +1,60 @@
+<!--
+@license
+Copyright (C) 2018 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-icon/iron-icon.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-repo-branch-picker">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      gr-labeled-autocomplete,
+      iron-icon {
+        display: inline-block;
+      }
+      iron-icon {
+        margin-bottom: 1.2em;
+      }
+    </style>
+    <div>
+      <gr-labeled-autocomplete
+          id="repoInput"
+          label="Repository"
+          placeholder="Select repo"
+          on-commit="_repoCommitted"
+          query="[[_repoQuery]]">
+      </gr-labeled-autocomplete>
+      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      <gr-labeled-autocomplete
+          id="branchInput"
+          label="Branch"
+          placeholder="Select branch"
+          disabled="[[_branchDisabled]]"
+          on-commit="_branchCommitted"
+          query="[[_query]]">
+      </gr-labeled-autocomplete>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-branch-picker.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
new file mode 100644
index 0000000..e2298c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -0,0 +1,109 @@
+/**
+ * @license
+ * Copyright (C) 2018 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';
+
+  const SUGGESTIONS_LIMIT = 15;
+  const REF_PREFIX = 'refs/heads/';
+
+  Polymer({
+    is: 'gr-repo-branch-picker',
+
+    properties: {
+      repo: {
+        type: String,
+        notify: true,
+        observer: '_repoChanged',
+      },
+      branch: {
+        type: String,
+        notify: true,
+      },
+      _branchDisabled: Boolean,
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoBranchesSuggestions.bind(this);
+        },
+      },
+      _repoQuery: {
+        type: Function,
+        value() {
+          return this._getRepoSuggestions.bind(this);
+        },
+      },
+    },
+
+    behaviors: [
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    attached() {
+      if (this.repo) {
+        this.$.repoInput.setText(this.repo);
+      }
+    },
+
+    ready() {
+      this._branchDisabled = !this.repo;
+    },
+
+    _getRepoBranchesSuggestions(input) {
+      if (!this.repo) { return Promise.resolve([]); }
+      if (input.startsWith(REF_PREFIX)) {
+        input = input.substring(REF_PREFIX.length);
+      }
+      return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+          .then(this._branchResponseToSuggestions.bind(this));
+    },
+
+    _getRepoSuggestions(input) {
+      return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
+          .then(this._repoResponseToSuggestions.bind(this));
+    },
+
+    _repoResponseToSuggestions(res) {
+      return res.map(repo => ({
+        name: repo.name,
+        value: this.singleDecodeURL(repo.id),
+      }));
+    },
+
+    _branchResponseToSuggestions(res) {
+      return Object.keys(res).map(key => {
+        let branch = res[key].ref;
+        if (branch.startsWith(REF_PREFIX)) {
+          branch = branch.substring(REF_PREFIX.length);
+        }
+        return {name: branch, value: branch};
+      });
+    },
+
+    _repoCommitted(e) {
+      this.repo = e.detail.value;
+    },
+
+    _branchCommitted(e) {
+      this.branch = e.detail.value;
+    },
+
+    _repoChanged() {
+      this.$.branchInput.clear();
+      this._branchDisabled = !this.repo;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
new file mode 100644
index 0000000..989e838
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-repo-branch-picker</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-branch-picker.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-branch-picker></gr-repo-branch-picker>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-branch-picker tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    suite('_getRepoSuggestions', () => {
+      setup(() => {
+        sandbox.stub(element.$.restAPI, 'getRepos')
+            .returns(Promise.resolve([
+              {
+                id: 'plugins%2Favatars-external',
+                name: 'plugins/avatars-external',
+              }, {
+                id: 'plugins%2Favatars-gravatar',
+                name: 'plugins/avatars-gravatar',
+              }, {
+                id: 'plugins%2Favatars%2Fexternal',
+                name: 'plugins/avatars/external',
+              }, {
+                id: 'plugins%2Favatars%2Fgravatar',
+                name: 'plugins/avatars/gravatar',
+              },
+            ]));
+      });
+
+      test('converts to suggestion objects', () => {
+        const input = 'plugins/avatars';
+        return element._getRepoSuggestions(input).then(suggestions => {
+          assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
+          const unencodedNames = [
+            'plugins/avatars-external',
+            'plugins/avatars-gravatar',
+            'plugins/avatars/external',
+            'plugins/avatars/gravatar',
+          ];
+          assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+          assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
+        });
+      });
+    });
+
+    suite('_getRepoBranchesSuggestions', () => {
+      setup(() => {
+        sandbox.stub(element.$.restAPI, 'getRepoBranches')
+            .returns(Promise.resolve([
+              {ref: 'refs/heads/stable-2.10'},
+              {ref: 'refs/heads/stable-2.11'},
+              {ref: 'refs/heads/stable-2.12'},
+              {ref: 'refs/heads/stable-2.13'},
+              {ref: 'refs/heads/stable-2.14'},
+              {ref: 'refs/heads/stable-2.15'},
+            ]));
+      });
+
+      test('converts to suggestion objects', () => {
+        const repo = 'gerrit';
+        const branchInput = 'stable-2.1';
+        element.repo = repo;
+        return element._getRepoBranchesSuggestions(branchInput)
+            .then(suggestions => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                  branchInput, repo, 15));
+              const refNames = [
+                'stable-2.10',
+                'stable-2.11',
+                'stable-2.12',
+                'stable-2.13',
+                'stable-2.14',
+                'stable-2.15',
+              ];
+              assert.deepEqual(suggestions.map(s => s.name), refNames);
+              assert.deepEqual(suggestions.map(s => s.value), refNames);
+            });
+      });
+
+      test('filters out ref prefix', () => {
+        const repo = 'gerrit';
+        const branchInput = 'refs/heads/stable-2.1';
+        element.repo = repo;
+        return element._getRepoBranchesSuggestions(branchInput)
+            .then(suggestions => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                  'stable-2.1', repo, 15));
+            });
+      });
+
+      test('does not query when repo is unset', () => {
+        return element._getRepoBranchesSuggestions('')
+            .then(() => {
+              assert.isFalse(element.$.restAPI.getRepoBranches.called);
+              element.repo = 'gerrit';
+              return element._getRepoBranchesSuggestions('');
+            })
+            .then(() => {
+              assert.isTrue(element.$.restAPI.getRepoBranches.called);
+            });
+      });
+    });
+  });
+</script>
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 cf680d2..c97c4c7 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
@@ -28,6 +28,15 @@
   Defs.patchRange;
 
   /**
+   * @typedef {{
+   *    url: string,
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   * }}
+   */
+  Defs.FetchRequest;
+
+  /**
    * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
    * - url is the URL for the request (excluding get params)
    * - errFn is a function to invoke when the request fails.
@@ -40,6 +49,8 @@
    *    cancelCondition: (function()|null|undefined),
    *    params: (Object|null|undefined),
    *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   *    reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.FetchJSONRequest;
@@ -50,9 +61,10 @@
    *   endpoint: string,
    *   patchNum: (string|number|null|undefined),
    *   errFn: (function(?Response, string=)|null|undefined),
-   *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeFetchRequest;
@@ -78,10 +90,29 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedUrl: (string|undefined),
+   *   reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.SendRequest;
 
+  /**
+   * @typedef {{
+   *   changeNum: (string|number),
+   *   method: string,
+   *   patchNum: (string|number|undefined),
+   *   endpoint: string,
+   *   body: (string|number|Object|null|undefined),
+   *   errFn: (function(?Response, string=)|null|undefined),
+   *   contentType: (string|null|undefined),
+   *   headers: (Object|undefined),
+   *   parseResponse: (boolean|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
+   * }}
+   */
+  Defs.ChangeSendRequest;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -90,8 +121,6 @@
   const MAX_PROJECT_RESULTS = 25;
   const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
   const PARENT_PATCH_NUM = 'PARENT';
-  const CHECK_SIGN_IN_DEBOUNCE_MS = 3 * 1000;
-  const CHECK_SIGN_IN_DEBOUNCER_NAME = 'checkCredentials';
   const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
   const Requests = {
@@ -102,6 +131,9 @@
       'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
   const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
+  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+      '/revisions/*';
 
   Polymer({
     is: 'gr-rest-api-interface',
@@ -130,11 +162,21 @@
      * @event auth-error
      */
 
+    /**
+     * Fired after an RPC completes.
+     *
+     * @event rpc-log
+     */
+
     properties: {
       _cache: {
         type: Object,
         value: {}, // Intentional to share the object across instances.
       },
+      _credentialCheck: {
+        type: Object,
+        value: {checking: false}, // Shared across instances.
+      },
       _sharedFetchPromises: {
         type: Object,
         value: {}, // Intentional to share the object across instances.
@@ -165,15 +207,14 @@
     /**
      * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
      * with timing and logging.
-     * @param {string} url
-     * @param {Object=} opt_fetchOptions
+     * @param {Defs.FetchRequest} req
      */
-    _fetch(url, opt_fetchOptions) {
+    _fetch(req) {
       const start = Date.now();
-      const xhr = this._auth.fetch(url, opt_fetchOptions);
+      const xhr = this._auth.fetch(req.url, req.fetchOptions);
 
       // Log the call after it completes.
-      xhr.then(res => this._logCall(url, opt_fetchOptions, start, res.status));
+      xhr.then(res => this._logCall(req, start, res.status));
 
       // Return the XHR directly (without the log).
       return xhr;
@@ -183,18 +224,27 @@
      * Log information about a REST call. Because the elapsed time is determined
      * by this method, it should be called immediately after the request
      * finishes.
-     * @param {string} url
-     * @param {Object|undefined} fetchOptions
+     * @param {Defs.FetchRequest} req
      * @param {number} startTime the time that the request was started.
      * @param {number} status the HTTP status of the response. The status value
      *     is used here rather than the response object so there is no way this
      *     method can read the body stream.
      */
-    _logCall(url, fetchOptions, startTime, status) {
-      const method = (fetchOptions && fetchOptions.method) ?
-          fetchOptions.method : 'GET';
-      const elapsed = (Date.now() - startTime) + 'ms';
-      console.log(['HTTP', status, method, elapsed, url].join(' '));
+    _logCall(req, startTime, status) {
+      const method = (req.fetchOptions && req.fetchOptions.method) ?
+          req.fetchOptions.method : 'GET';
+      const elapsed = (Date.now() - startTime);
+      console.log([
+        'HTTP',
+        status,
+        method,
+        elapsed + 'ms',
+        req.anonymizedUrl || req.url,
+      ].join(' '));
+      if (req.anonymizedUrl) {
+        this.fire('rpc-log',
+            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+      }
     },
 
     /**
@@ -206,7 +256,12 @@
      */
     _fetchRawJSON(req) {
       const urlWithParams = this._urlWithParams(req.url, req.params);
-      return this._fetch(urlWithParams, req.fetchOptions).then(res => {
+      const fetchReq = {
+        url: urlWithParams,
+        fetchOptions: req.fetchOptions,
+        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+      };
+      return this._fetch(fetchReq).then(res => {
         if (req.cancelCondition && req.cancelCondition()) {
           res.body.cancel();
           return;
@@ -215,11 +270,7 @@
       }).catch(err => {
         const isLoggedIn = !!this._cache['/accounts/self/detail'];
         if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
-          if (!this.isDebouncerActive(CHECK_SIGN_IN_DEBOUNCER_NAME)) {
-            this.checkCredentials();
-          }
-          this.debounce(CHECK_SIGN_IN_DEBOUNCER_NAME, this.checkCredentials,
-              CHECK_SIGN_IN_DEBOUNCE_MS);
+          this.checkCredentials();
           return;
         }
         if (req.errFn) {
@@ -311,10 +362,16 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL({url: '/config/server/info'});
+        return this._fetchSharedCacheURL({
+          url: '/config/server/info',
+          reportUrlAsIs: true,
+        });
       }
 
-      return this._fetchJSON({url: '/config/server/info'});
+      return this._fetchJSON({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      });
     },
 
     getRepo(repo, opt_errFn) {
@@ -323,6 +380,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo),
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -332,6 +390,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo) + '/config',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -340,6 +399,7 @@
       // supports it.
       return this._fetchSharedCacheURL({
         url: '/access/?project=' + encodeURIComponent(repo),
+        anonymizedUrl: '/access/?project=*',
       });
     },
 
@@ -349,6 +409,7 @@
       return this._fetchSharedCacheURL({
         url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards?inherited',
       });
     },
 
@@ -361,6 +422,7 @@
         url: `/projects/${encodeName}/config`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -374,6 +436,7 @@
         url: `/projects/${encodeName}/gc`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/gc',
       });
     },
 
@@ -391,6 +454,7 @@
         url: `/projects/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -406,6 +470,7 @@
         url: `/groups/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*',
       });
     },
 
@@ -413,6 +478,7 @@
       return this._fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/detail',
       });
     },
 
@@ -432,6 +498,7 @@
         url: `/projects/${encodeName}/branches/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -451,6 +518,7 @@
         url: `/projects/${encodeName}/tags/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -471,6 +539,7 @@
         url: `/projects/${encodeName}/branches/${encodeBranch}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -491,6 +560,7 @@
         url: `/projects/${encodeName}/tags/${encodeTag}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -500,7 +570,11 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
+      const req = {
+        url: `/groups/?owned&q=${encodeName}`,
+        anonymizedUrl: '/groups/owned&q=*',
+      };
+      return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -509,12 +583,15 @@
       return this._fetchJSON({
         url: `/groups/${encodeName}/members/`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/members',
       });
     },
 
     getIncludedGroup(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._fetchJSON({url: `/groups/${encodeName}/groups/`});
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+        anonymizedUrl: '/groups/*/groups',
+      });
     },
 
     saveGroupName(groupId, name) {
@@ -523,6 +600,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
+        anonymizedUrl: '/groups/*/name',
       });
     },
 
@@ -532,6 +610,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
+        anonymizedUrl: '/groups/*/owner',
       });
     },
 
@@ -541,6 +620,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
+        anonymizedUrl: '/groups/*/description',
       });
     },
 
@@ -550,6 +630,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
+        anonymizedUrl: '/groups/*/options',
       });
     },
 
@@ -557,6 +638,7 @@
       return this._fetchSharedCacheURL({
         url: '/groups/' + group + '/log.audit',
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/log.audit',
       });
     },
 
@@ -567,6 +649,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         parseResponse: true,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -577,6 +660,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/groups/*',
       };
       return this._send(req).then(response => {
         if (response.ok) {
@@ -591,6 +675,7 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/members/${encodeMember}`,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -600,11 +685,15 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+        anonymizedUrl: '/groups/*/groups/*',
       });
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL({url: '/config/server/version'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/version',
+        reportUrlAsIs: true,
+      });
     },
 
     getDiffPreferences() {
@@ -612,6 +701,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.diff',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -642,6 +732,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.edit',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -683,6 +774,7 @@
         url: '/accounts/self/preferences',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -698,6 +790,7 @@
         url: '/accounts/self/preferences.diff',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -713,12 +806,14 @@
         url: '/accounts/self/preferences.edit',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     getAccount() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/detail',
+        reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
             this._cache['/accounts/self/detail'] = null;
@@ -728,7 +823,10 @@
     },
 
     getExternalIds() {
-      return this._fetchJSON({url: '/accounts/self/external.ids'});
+      return this._fetchJSON({
+        url: '/accounts/self/external.ids',
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountIdentity(id) {
@@ -737,6 +835,7 @@
         url: '/accounts/self/external.ids:delete',
         body: id,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -747,11 +846,15 @@
     getAccountDetails(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
+        anonymizedUrl: '/accounts/*/detail',
       });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/emails',
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -763,6 +866,7 @@
         method: 'PUT',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/account/self/emails/*',
       });
     },
 
@@ -775,6 +879,7 @@
         method: 'DELETE',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/email/*',
       });
     },
 
@@ -784,8 +889,13 @@
      */
     setPreferredAccountEmail(email, opt_errFn) {
       const encodedEmail = encodeURIComponent(email);
-      const url = `/accounts/self/emails/${encodedEmail}/preferred`;
-      return this._send({method: 'PUT', url, errFn: opt_errFn}).then(() => {
+      const req = {
+        method: 'PUT',
+        url: `/accounts/self/emails/${encodedEmail}/preferred`,
+        errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/emails/*/preferred',
+      };
+      return this._send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
         const cachedEmails = this._cache['/accounts/self/emails'];
@@ -827,6 +937,7 @@
         body: {name},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
@@ -843,6 +954,7 @@
         body: {username},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
@@ -859,6 +971,7 @@
         body: {status},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
@@ -867,15 +980,22 @@
     getAccountStatus(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
+        anonymizedUrl: '/accounts/*/status',
       });
     },
 
     getAccountGroups() {
-      return this._fetchJSON({url: '/accounts/self/groups'});
+      return this._fetchJSON({
+        url: '/accounts/self/groups',
+        reportUrlAsIs: true,
+      });
     },
 
     getAccountAgreements() {
-      return this._fetchJSON({url: '/accounts/self/agreements'});
+      return this._fetchJSON({
+        url: '/accounts/self/agreements',
+        reportUrlAsIs: true,
+      });
     },
 
     saveAccountAgreement(name) {
@@ -883,6 +1003,7 @@
         method: 'PUT',
         url: '/accounts/self/agreements',
         body: name,
+        reportUrlAsIs: true,
       });
     },
 
@@ -898,6 +1019,7 @@
       }
       return this._fetchSharedCacheURL({
         url: '/accounts/self/capabilities' + queryString,
+        anonymizedUrl: '/accounts/self/capabilities?q=*',
       });
     },
 
@@ -920,8 +1042,13 @@
     },
 
     checkCredentials() {
+      if (this._credentialCheck.checking) {
+        return;
+      }
+      this._credentialCheck.checking = true;
+      const req = {url: '/accounts/self/detail', reportUrlAsIs: true};
       // Skip the REST response cache.
-      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+      return this._fetchRawJSON(req).then(res => {
         if (!res) { return; }
         if (res.status === 403) {
           this.fire('auth-error');
@@ -930,29 +1057,35 @@
           return this.getResponseObject(res);
         }
       }).then(res => {
+        this._credentialCheck.checking = false;
         if (res) {
           this._cache['/accounts/self/detail'] = res;
         }
         return res;
+      }).catch(err => {
+        this._credentialCheck.checking = false;
       });
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/preferences',
+        reportUrlAsIs: true,
+      });
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
-              .then(res => {
-                if (this._isNarrowScreen()) {
-                  res.default_diff_view = DiffViewMode.UNIFIED;
-                } else {
-                  res.default_diff_view = res.diff_view;
-                }
-                return Promise.resolve(res);
-              });
+          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+          return this._fetchSharedCacheURL(req).then(res => {
+            if (this._isNarrowScreen()) {
+              res.default_diff_view = DiffViewMode.UNIFIED;
+            } else {
+              res.default_diff_view = res.diff_view;
+            }
+            return Promise.resolve(res);
+          });
         }
 
         return Promise.resolve({
@@ -968,6 +1101,7 @@
     getWatchedProjects() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/watched.projects',
+        reportUrlAsIs: true,
       });
     },
 
@@ -982,6 +1116,7 @@
         body: projects,
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -995,6 +1130,7 @@
         url: '/accounts/self/watched.projects:delete',
         body: projects,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1059,7 +1195,12 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this._fetchJSON({url: '/changes/', params}).then(response => {
+      const req = {
+        url: '/changes/',
+        params,
+        reportUrlAsIs: true,
+      };
+      return this._fetchJSON(req).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -1153,6 +1294,7 @@
           cancelCondition: opt_cancelCondition,
           params: {O: params},
           fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + params,
         };
         return this._fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
@@ -1193,6 +1335,7 @@
         changeNum,
         endpoint: '/commit?links',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1213,6 +1356,7 @@
         endpoint: '/files',
         patchNum: patchRange.patchNum,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1222,10 +1366,16 @@
      */
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
+      let anonymizedEndpoint = endpoint;
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+        anonymizedEndpoint += '&base=*';
       }
-      return this._getChangeURLAndFetch({changeNum, endpoint});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        anonymizedEndpoint,
+      });
     },
 
     /**
@@ -1239,6 +1389,7 @@
         changeNum,
         endpoint: `/files?q=${encodeURIComponent(query)}`,
         patchNum,
+        anonymizedEndpoint: '/files?q=*',
       });
     },
 
@@ -1267,7 +1418,12 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      const req = {changeNum, endpoint: '/actions', patchNum};
+      const req = {
+        changeNum,
+        endpoint: '/actions',
+        patchNum,
+        reportEndpointAsIs: true,
+      };
       return this._getChangeURLAndFetch(req).then(revisionActions => {
         // The rebase button on change screen is always enabled.
         if (revisionActions.rebase) {
@@ -1292,6 +1448,7 @@
         endpoint: '/suggest_reviewers',
         errFn: opt_errFn,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1299,7 +1456,11 @@
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/in',
+        reportEndpointAsIs: true,
+      });
     },
 
     _computeFilter(filter) {
@@ -1325,6 +1486,7 @@
       return this._fetchSharedCacheURL({
         url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
             this._computeFilter(filter),
+        anonymizedUrl: '/groups/?*',
       });
     },
 
@@ -1335,13 +1497,38 @@
      * @return {!Promise<?Object>}
      */
     getRepos(filter, reposPerPage, opt_offset) {
+      const defaultFilter = 'state:active OR state:read-only';
+      const namePartDelimiters = /[@.\-\s\/_]/g;
       const offset = opt_offset || 0;
 
+      if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+        // The query language specifies hyphens as operators. Split the string
+        // by hyphens and 'AND' the parts together as 'inname:' queries.
+        // If the filter includes a semicolon, the user is using a more complex
+        // query so we trust them and don't do any magic under the hood.
+        const originalFilter = filter;
+        filter = '';
+        originalFilter.split(namePartDelimiters).forEach(part => {
+          if (part) {
+            filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+          }
+        });
+      }
+      // Check if filter is now empty which could be either because the user did
+      // not provide it or because the user provided only a split character.
+      if (!filter) {
+        filter = defaultFilter;
+      }
+
+      filter = filter.trim();
+      const encodedFilter = encodeURIComponent(filter);
+
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
       return this._fetchSharedCacheURL({
-        url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
-            this._computeFilter(filter),
+        url: `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+            `&query=${encodedFilter}`,
+        anonymizedUrl: '/projects/?*',
       });
     },
 
@@ -1352,6 +1539,7 @@
         method: 'PUT',
         url: `/projects/${encodeURIComponent(repo)}/HEAD`,
         body: {ref},
+        anonymizedUrl: '/projects/*/HEAD',
       });
     },
 
@@ -1371,7 +1559,11 @@
       const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches?*',
+      });
     },
 
     /**
@@ -1391,7 +1583,11 @@
           encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags',
+      });
     },
 
     /**
@@ -1406,7 +1602,11 @@
       const encodedFilter = this._computeFilter(filter);
       const n = pluginsPerPage + 1;
       const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/plugins/?all',
+      });
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
@@ -1415,6 +1615,7 @@
       return this._fetchJSON({
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1425,6 +1626,7 @@
         method: 'POST',
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         body: repoInfo,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1434,6 +1636,7 @@
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
         parseResponse: true,
+        anonymizedUrl: '/projects/*/access:review',
       });
     },
 
@@ -1449,6 +1652,7 @@
         url: '/groups/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1468,6 +1672,7 @@
         url: '/projects/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1486,6 +1691,7 @@
         url: '/accounts/',
         errFn: opt_errFn,
         params,
+        anonymizedUrl: '/accounts/?n=*',
       });
     },
 
@@ -1521,6 +1727,7 @@
         changeNum,
         endpoint: '/related',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1528,6 +1735,7 @@
       return this._getChangeURLAndFetch({
         changeNum,
         endpoint: '/submitted_together',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1540,7 +1748,11 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/conflicts:*',
+      });
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1558,7 +1770,11 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/change:*',
+      });
     },
 
     getChangesWithSameTopic(topic) {
@@ -1572,7 +1788,11 @@
         O: options,
         q: 'status:open topic:' + topic,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/topic:*',
+      });
     },
 
     getReviewedFiles(changeNum, patchNum) {
@@ -1580,6 +1800,7 @@
         changeNum,
         endpoint: '/files?reviewed',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1591,10 +1812,14 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
-      const method = reviewed ? 'PUT' : 'DELETE';
-      const endpoint = `/files/${encodeURIComponent(path)}/reviewed`;
-      return this.getChangeURLAndSend(changeNum, method, patchNum, endpoint,
-          null, opt_errFn);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: reviewed ? 'PUT' : 'DELETE',
+        patchNum,
+        endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+        errFn: opt_errFn,
+        anonymizedEndpoint: '/files/*/reviewed',
+      });
     },
 
     /**
@@ -1626,6 +1851,7 @@
           changeNum,
           endpoint: '/edit/',
           params,
+          reportEndpointAsIs: true,
         });
       });
     },
@@ -1656,6 +1882,7 @@
           base_commit: opt_baseCommit,
         },
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1695,10 +1922,15 @@
      * @param {?function(?Response, string=)=} opt_errFn
      */
     _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
-      const e = `/files/${encodeURIComponent(path)}/content`;
-      const headers = {Accept: 'application/json'};
-      return this.getChangeURLAndSend(changeNum, 'GET', patchNum, e, null,
-          opt_errFn, null, headers);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'GET',
+        patchNum,
+        endpoint: `/files/${encodeURIComponent(path)}/content`,
+        errFn: opt_errFn,
+        headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/files/*/content',
+      });
     },
 
     /**
@@ -1707,62 +1939,117 @@
      * @param {string} path
      */
     _getFileInChangeEdit(changeNum, path) {
-      const e = '/edit/' + encodeURIComponent(path);
-      const headers = {Accept: 'application/json'};
-      return this.getChangeURLAndSend(changeNum, 'GET', null, e, null, null,
-          null, headers);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'GET',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     rebaseChangeEdit(changeNum) {
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit:rebase');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit:rebase',
+        reportEndpointAsIs: true,
+      });
     },
 
     deleteChangeEdit(changeNum) {
-      return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/edit');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/edit',
+        reportEndpointAsIs: true,
+      });
     },
 
     restoreFileInChangeEdit(changeNum, restore_path) {
-      const p = {restore_path};
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit',
+        body: {restore_path},
+        reportEndpointAsIs: true,
+      });
     },
 
     renameFileInChangeEdit(changeNum, old_path, new_path) {
-      const p = {old_path, new_path};
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit',
+        body: {old_path, new_path},
+        reportEndpointAsIs: true,
+      });
     },
 
     deleteFileInChangeEdit(changeNum, path) {
-      const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'DELETE', null, e);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     saveChangeEdit(changeNum, path, contents) {
-      const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents, null,
-          'text/plain');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/edit/' + encodeURIComponent(path),
+        body: contents,
+        contentType: 'text/plain',
+        anonymizedEndpoint: '/edit/*',
+      });
     },
 
     // Deprecated, prefer to use putChangeCommitMessage instead.
     saveChangeCommitMessageEdit(changeNum, message) {
-      const p = {message};
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/edit:message',
-          p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/edit:message',
+        body: {message},
+        reportEndpointAsIs: true,
+      });
     },
 
     publishChangeEdit(changeNum) {
-      return this.getChangeURLAndSend(changeNum, 'POST', null,
-          '/edit:publish');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/edit:publish',
+        reportEndpointAsIs: true,
+      });
     },
 
     putChangeCommitMessage(changeNum, message) {
-      const p = {message};
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/message', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/message',
+        body: {message},
+        reportEndpointAsIs: true,
+      });
     },
 
     saveChangeStarred(changeNum, starred) {
-      const url = '/accounts/self/starred.changes/' + changeNum;
-      const method = starred ? 'PUT' : 'DELETE';
-      return this._send({method, url});
+      return this._send({
+        method: starred ? 'PUT' : 'DELETE',
+        url: '/accounts/self/starred.changes/' + changeNum,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+    },
+
+    saveChangeReviewed(changeNum, reviewed) {
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: reviewed ? '/reviewed' : '/unreviewed',
+      });
     },
 
     /**
@@ -1788,7 +2075,12 @@
       }
       const url = req.url.startsWith('http') ?
           req.url : this.getBaseUrl() + req.url;
-      const xhr = this._fetch(url, options).then(response => {
+      const fetchReq = {
+        url,
+        fetchOptions: options,
+        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+      };
+      const xhr = this._fetch(fetchReq).then(response => {
         if (!response.ok) {
           if (req.errFn) {
             return req.errFn.call(undefined, response);
@@ -1842,15 +2134,16 @@
      *     index.
      * @param {number|string} patchNum
      * @param {string} path
+     * @param {string=} opt_whitespace the ignore-whitespace level for the diff
+     *     algorithm.
      * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
      */
-    getDiff(changeNum, basePatchNum, patchNum, path,
-        opt_errFn, opt_cancelCondition) {
+    getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
+        opt_errFn) {
       const params = {
         context: 'ALL',
         intraline: null,
-        whitespace: 'IGNORE_NONE',
+        whitespace: opt_whitespace || 'IGNORE_NONE',
       };
       if (this.isMergeParent(basePatchNum)) {
         params.parent = this.getParentIndex(basePatchNum);
@@ -1864,8 +2157,8 @@
         endpoint,
         patchNum,
         errFn: opt_errFn,
-        cancelCondition: opt_cancelCondition,
         params,
+        anonymizedEndpoint: '/files/*/diff',
       });
     },
 
@@ -1957,6 +2250,7 @@
           changeNum,
           endpoint,
           patchNum: opt_patchNum,
+          reportEndpointAsIs: true,
         });
       };
 
@@ -2047,8 +2341,10 @@
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
       const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
+      let anonymizedEndpoint = endpoint;
       if (draft.id) {
         endpoint += '/' + draft.id;
+        anonymizedEndpoint += '/*';
       }
       let body;
       if (method === 'PUT') {
@@ -2059,8 +2355,16 @@
         this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
       }
 
-      const promise = this.getChangeURLAndSend(changeNum, method, patchNum,
-          endpoint, body);
+      const req = {
+        changeNum,
+        method,
+        patchNum,
+        endpoint,
+        body,
+        anonymizedEndpoint,
+      };
+
+      const promise = this._getChangeURLAndSend(req);
       this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
 
       if (isCreate) {
@@ -2074,11 +2378,12 @@
       return this._fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
+        anonymizedUrl: '/projects/*/comments/*',
       });
     },
 
     _fetchB64File(url) {
-      return this._fetch(this.getBaseUrl() + url)
+      return this._fetch({url: this.getBaseUrl() + url})
           .then(response => {
             if (!response.ok) { return Promise.reject(response.statusText); }
             const type = response.headers.get('X-FYI-Content-Type');
@@ -2173,9 +2478,14 @@
      * parameter.
      */
     setChangeTopic(changeNum, topic) {
-      const p = {topic};
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/topic', p)
-          .then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/topic',
+        body: {topic},
+        parseResponse: true,
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -2184,14 +2494,21 @@
      * parameter.
      */
     setChangeHashtag(changeNum, hashtag) {
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/hashtags',
-          hashtag).then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/hashtags',
+        body: hashtag,
+        parseResponse: true,
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountHttpPassword() {
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2206,11 +2523,15 @@
         url: '/accounts/self/password.http',
         body: {generate: true},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/sshkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountSSHKey(key) {
@@ -2219,6 +2540,7 @@
         url: '/accounts/self/sshkeys',
         body: key,
         contentType: 'plain/text',
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(response => {
@@ -2237,15 +2559,24 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
+        anonymizedUrl: '/accounts/self/sshkeys/*',
       });
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
+      return this._fetchJSON({
+        url: '/accounts/self/gpgkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountGPGKey(key) {
-      const req = {method: 'POST', url: '/accounts/self/gpgkeys', body: key};
+      const req = {
+        method: 'POST',
+        url: '/accounts/self/gpgkeys',
+        body: key,
+        reportUrlAsIs: true,
+      };
       return this._send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
@@ -2263,18 +2594,27 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
+        anonymizedUrl: '/accounts/self/gpgkeys/*',
       });
     },
 
     deleteVote(changeNum, account, label) {
-      const e = `/reviewers/${account}/votes/${encodeURIComponent(label)}`;
-      return this.getChangeURLAndSend(changeNum, 'DELETE', null, e);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+        anonymizedEndpoint: '/reviewers/*/votes/*',
+      });
     },
 
     setDescription(changeNum, patchNum, desc) {
-      const p = {description: desc};
-      return this.getChangeURLAndSend(changeNum, 'PUT', patchNum,
-          '/description', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT', patchNum,
+        endpoint: '/description',
+        body: {description: desc},
+        reportUrlAsIs: true,
+      });
     },
 
     confirmEmail(token) {
@@ -2282,6 +2622,7 @@
         method: 'PUT',
         url: '/config/server/email.confirm',
         body: {token},
+        reportUrlAsIs: true,
       };
       return this._send(req).then(response => {
         if (response.status === 204) {
@@ -2295,16 +2636,27 @@
       return this._fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     setAssignee(changeNum, assignee) {
-      const p = {assignee};
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/assignee', p);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: '/assignee',
+        body: {assignee},
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAssignee(changeNum) {
-      return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/assignee');
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'DELETE',
+        endpoint: '/assignee',
+        reportUrlAsIs: true,
+      });
     },
 
     probePath(path) {
@@ -2319,16 +2671,22 @@
      * @param {number|string=} opt_message
      */
     startWorkInProgress(changeNum, opt_message) {
-      const payload = {};
+      const body = {};
       if (opt_message) {
-        payload.message = opt_message;
+        body.message = opt_message;
       }
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/wip', payload)
-          .then(response => {
-            if (response.status === 204) {
-              return 'Change marked as Work In Progress.';
-            }
-          });
+      const req = {
+        changeNum,
+        method: 'POST',
+        endpoint: '/wip',
+        body,
+        reportUrlAsIs: true,
+      };
+      return this._getChangeURLAndSend(req).then(response => {
+        if (response.status === 204) {
+          return 'Change marked as Work In Progress.';
+        }
+      });
     },
 
     /**
@@ -2337,8 +2695,14 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     startReview(changeNum, opt_body, opt_errFn) {
-      return this.getChangeURLAndSend(changeNum, 'POST', null, '/ready',
-          opt_body, opt_errFn);
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        endpoint: '/ready',
+        body: opt_body,
+        errFn: opt_errFn,
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -2347,10 +2711,15 @@
      * parameter.
      */
     deleteComment(changeNum, patchNum, commentID, reason) {
-      const endpoint = `/comments/${commentID}/delete`;
-      const payload = {reason};
-      return this.getChangeURLAndSend(changeNum, 'POST', patchNum, endpoint,
-          payload).then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'POST',
+        patchNum,
+        endpoint: `/comments/${commentID}/delete`,
+        body: {reason},
+        parseResponse: true,
+        anonymizedEndpoint: '/comments/*/delete',
+      });
     },
 
     /**
@@ -2365,6 +2734,7 @@
       return this._fetchJSON({
         url: `/changes/?q=change:${changeNum}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/changes/?q=change:*',
       }).then(res => {
         if (!res || !res.length) { return null; }
         return res[0];
@@ -2411,27 +2781,26 @@
     /**
      * Alias for _changeBaseURL.then(send).
      * @todo(beckysiegel) clean up comments
-     * @param {string|number} changeNum
-     * @param {string} method
-     * @param {?string|number} patchNum gets passed as null.
-     * @param {?string} endpoint gets passed as null.
-     * @param {?Object|number|string=} opt_payload gets passed as null, string,
-     *    Object, or number.
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @param {?=} opt_contentType
-     * @param {Object=} opt_headers
+     * @param {Defs.ChangeSendRequest} req
      * @return {!Promise<!Object>}
      */
-    getChangeURLAndSend(changeNum, method, patchNum, endpoint, opt_payload,
-        opt_errFn, opt_contentType, opt_headers) {
-      return this._changeBaseURL(changeNum, patchNum).then(url => {
+    _getChangeURLAndSend(req) {
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+
+      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._send({
-          method,
-          url: url + endpoint,
-          body: opt_payload,
-          errFn: opt_errFn,
-          contentType: opt_contentType,
-          headers: opt_headers,
+          method: req.method,
+          url: url + req.endpoint,
+          body: req.body,
+          errFn: req.errFn,
+          contentType: req.contentType,
+          headers: req.headers,
+          parseResponse: req.parseResponse,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2442,18 +2811,45 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndFetch(req) {
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
-          cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
 
     /**
+     * Execute a change action or revision action on a change.
+     * @param {number} changeNum
+     * @param {string} method
+     * @param {string} endpoint
+     * @param {string|number|undefined} opt_patchNum
+     * @param {Object=} opt_payload
+     * @param {?function(?Response, string=)=} opt_errFn
+     * @return {Promise}
+     */
+    executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
+        opt_errFn) {
+      return this._getChangeURLAndSend({
+        changeNum,
+        method,
+        patchNum: opt_patchNum,
+        endpoint,
+        body: opt_payload,
+        errFn: opt_errFn,
+      });
+    },
+
+    /**
      * Get blame information for the given diff.
      * @param {string|number} changeNum
      * @param {string|number} patchNum
@@ -2469,6 +2865,7 @@
         endpoint: `/files/${encodedPath}/blame`,
         patchNum,
         params: opt_base ? {base: 't'} : undefined,
+        anonymizedEndpoint: '/files/*/blame',
       });
     },
 
@@ -2513,13 +2910,20 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
+      return this._fetchSharedCacheURL({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards/*',
+      });
     },
 
     getMergeable(changeNum) {
-      return this.getChangeURLAndSend(changeNum, 'GET', null,
-          '/revisions/current/mergeable')
-          .then(this.getResponseObject.bind(this));
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/revisions/current/mergeable',
+        parseResponse: true,
+        reportEndpointAsIs: true,
+      });
     },
   });
 })();
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 5ef4c41..d9656e4 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
@@ -485,6 +485,26 @@
       });
     });
 
+    test('checkCredentials promise rejection', () => {
+      window.fetch.restore();
+      element._cache['/accounts/self/detail'] = true;
+      sandbox.spy(element, 'checkCredentials');
+      sandbox.stub(window, 'fetch', url => {
+        return Promise.reject({message: 'Failed to fetch'});
+      });
+      return element.getConfig(true)
+          .catch(err => undefined)
+          .then(() => {
+            // When the top-level fetch call throws an error, it invokes
+            // checkCredentials, which in turn makes another fetch call.
+            // The second fetch call also fails, which leads to a second
+            // invocation of checkCredentials, which should immediately
+            // return instead of making further fetch calls.
+            assert.isTrue(element.checkCredentials.calledTwice);
+            assert.isTrue(window.fetch.calledTwice);
+          });
+    });
+
     test('legacy n,z key in change url is replaced', () => {
       const stub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve([]));
@@ -691,11 +711,10 @@
       sandbox.spy(element, '_send');
       element.confirmEmail('foo');
       assert.isTrue(element._send.calledOnce);
-      assert.deepEqual(element._send.lastCall.args[0], {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token: 'foo'},
-      });
+      assert.equal(element._send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._send.lastCall.args[0].url,
+          '/config/server/email.confirm');
+      assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
     });
 
     test('GrReviewerUpdatesParser.parse is used', () => {
@@ -725,7 +744,7 @@
     suite('draft comments', () => {
       test('_sendDiffDraftRequest pending requests tracked', () => {
         const obj = element._pendingRequests;
-        sandbox.stub(element, 'getChangeURLAndSend', () => mockPromise());
+        sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
         assert.notOk(element.hasPendingDiffDrafts());
 
         element._sendDiffDraftRequest(null, null, null, {});
@@ -747,7 +766,7 @@
       suite('_failForCreate200', () => {
         test('_sendDiffDraftRequest checks for 200 on create', () => {
           const sendPromise = Promise.resolve();
-          sandbox.stub(element, 'getChangeURLAndSend').returns(sendPromise);
+          sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
           const failStub = sandbox.stub(element, '_failForCreate200')
               .returns(Promise.resolve());
           return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
@@ -757,7 +776,7 @@
         });
 
         test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-          sandbox.stub(element, 'getChangeURLAndSend')
+          sandbox.stub(element, '_getChangeURLAndSend')
               .returns(Promise.resolve());
           const failStub = sandbox.stub(element, '_failForCreate200')
               .returns(Promise.resolve());
@@ -841,35 +860,54 @@
     });
 
     test('startWorkInProgress', () => {
-      sandbox.stub(element, 'getChangeURLAndSend')
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
           .returns(Promise.resolve('ok'));
       element.startWorkInProgress('42');
-      assert.isTrue(element.getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/wip', {}));
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+      assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
       element.startWorkInProgress('42', 'revising...');
-      assert.isTrue(element.getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/wip', {message: 'revising...'}));
+      assert.isTrue(sendStub.calledTwice);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+      assert.deepEqual(sendStub.lastCall.args[0].body,
+          {message: 'revising...'});
     });
 
     test('startReview', () => {
-      sandbox.stub(element, 'getChangeURLAndSend')
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
           .returns(Promise.resolve({}));
       element.startReview('42', {message: 'Please review.'});
-      assert.isTrue(element.getChangeURLAndSend.calledWith(
-          '42', 'POST', null, '/ready', {message: 'Please review.'}));
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+      assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
+      assert.deepEqual(sendStub.lastCall.args[0].body,
+          {message: 'Please review.'});
     });
 
-    test('deleteComment', done => {
-      sandbox.stub(element, 'getChangeURLAndSend').returns(Promise.resolve());
-      sandbox.stub(element, 'getResponseObject').returns('some response');
-      element.deleteComment('foo', 'bar', '01234', 'removal reason')
+    test('deleteComment', () => {
+      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+          .returns(Promise.resolve('some response'));
+      return element.deleteComment('foo', 'bar', '01234', 'removal reason')
           .then(response => {
             assert.equal(response, 'some response');
-            done();
+            assert.isTrue(sendStub.calledOnce);
+            assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
+            assert.equal(sendStub.lastCall.args[0].method, 'POST');
+            assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
+            assert.equal(sendStub.lastCall.args[0].endpoint,
+                '/comments/01234/delete');
+            assert.deepEqual(sendStub.lastCall.args[0].body,
+                {reason: 'removal reason'});
           });
-      assert.isTrue(element.getChangeURLAndSend.calledWith(
-          'foo', 'POST', 'bar', '/comments/01234/delete',
-          {reason: 'removal reason'}));
     });
 
     test('createRepo encodes name', () => {
@@ -885,41 +923,75 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args[0], {
-          changeNum: '42',
-          endpoint: '/files?q=test%2Fpath.js',
-          patchNum: 'edit',
-        });
+        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+        assert.equal(fetchStub.lastCall.args[0].endpoint,
+            '/files?q=test%2Fpath.js');
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
       });
     });
 
-    test('getRepos', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('test', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&m=test');
+    suite('getRepos', () => {
+      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
 
-      element.getRepos(null, 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0');
+      setup(() => {
+        sandbox.stub(element, '_fetchSharedCacheURL');
+      });
 
-      element.getRepos('test', 25, 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=25&m=test');
-    });
+      test('normal use', () => {
+        element.getRepos('test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=test');
 
-    test('getRepos filter', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('test/test/test', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest');
-    });
+        element.getRepos(null, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
 
-    test('getRepos filter regex', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('^test.*', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&r=%5Etest.*');
+        element.getRepos('test', 25, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=25&query=test');
+      });
+
+      test('with blank', () => {
+        element.getRepos('test/test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
+      });
+
+      test('with hyphen', () => {
+        element.getRepos('foo-bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with leading hyphen', () => {
+        element.getRepos('-bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Abar');
+      });
+
+      test('with trailing hyphen', () => {
+        element.getRepos('foo-bar-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('hyphen only', () => {
+        element.getRepos('-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
+      });
     });
 
     test('getGroups filter regex', () => {
@@ -1129,11 +1201,18 @@
       });
     });
 
-    test('getChangeURLAndSend', () => {
+    test('_getChangeURLAndSend', () => {
       element._projectLookup = {1: 'test'};
       const sendStub = sandbox.stub(element, '_send')
           .returns(Promise.resolve());
-      return element.getChangeURLAndSend(1, 'POST', 1, '/test').then(() => {
+
+      const req = {
+        changeNum: 1,
+        method: 'POST',
+        patchNum: 1,
+        endpoint: '/test',
+      };
+      return element._getChangeURLAndSend(req).then(() => {
         assert.isTrue(sendStub.calledOnce);
         assert.equal(sendStub.lastCall.args[0].method, 'POST');
         assert.equal(sendStub.lastCall.args[0].url,
@@ -1161,18 +1240,18 @@
     });
 
     test('setChangeTopic', () => {
-      const sendSpy = sandbox.spy(element, 'getChangeURLAndSend');
+      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
       return element.setChangeTopic(123, 'foo-bar').then(() => {
         assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[4], {topic: 'foo-bar'});
+        assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
       });
     });
 
     test('setChangeHashtag', () => {
-      const sendSpy = sandbox.spy(element, 'getChangeURLAndSend');
+      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
       return element.setChangeHashtag(123, 'foo-bar').then(() => {
         assert.isTrue(sendSpy.calledOnce);
-        assert.equal(sendSpy.lastCall.args[4], 'foo-bar');
+        assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
       });
     });
 
@@ -1271,7 +1350,7 @@
     });
 
     test('getFileContent', () => {
-      sandbox.stub(element, 'getChangeURLAndSend')
+      sandbox.stub(element, '_getChangeURLAndSend')
           .returns(Promise.resolve({
             ok: 'true',
             headers: {
@@ -1341,12 +1420,25 @@
       sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
       const startTime = 123;
       sandbox.stub(Date, 'now').returns(startTime);
-      return element._fetch(url, fetchOptions).then(() => {
+      const req = {url, fetchOptions};
+      return element._fetch(req).then(() => {
         assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(
-            url, fetchOptions, startTime, response.status));
+        assert.isTrue(logStub.calledWith(req, startTime, response.status));
         assert.isFalse(response.text.called);
       });
     });
+
+    test('_logCall only reports requests with anonymized URLss', () => {
+      sandbox.stub(Date, 'now').returns(200);
+      const handler = sinon.stub();
+      element.addEventListener('rpc-log', handler);
+
+      element._logCall({url: 'url'}, 100, 200);
+      assert.isFalse(handler.called);
+
+      element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+      flushAsynchronousOperations();
+      assert.isTrue(handler.calledOnce);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
new file mode 100644
index 0000000..fe6ed88
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
@@ -0,0 +1,58 @@
+<!--
+@license
+Copyright (C) 2018 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="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+
+<dom-module id="gr-shell-command">
+  <template>
+    <style include="shared-styles">
+      .commandContainer {
+        margin-bottom: .75em;
+      }
+      .commandContainer {
+        background-color: var(--shell-command-background-color);
+        padding: .5em .5em .5em 2.5em;
+        position: relative;
+        width: 100%;
+      }
+      .commandContainer:before {
+        background: var(--shell-command-decoration-background-color);
+        bottom: 0;
+        box-sizing: border-box;
+        content: '$';
+        display: block;
+        left: 0;
+        padding: .8em;
+        position: absolute;
+        top: 0;
+        width: 2em;
+      }
+      .commandContainer gr-copy-clipboard {
+        --text-container-style: {
+          border: none;
+        }
+      }
+    </style>
+    <label>[[label]]</label>
+    <div class="commandContainer">
+      <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
+    </div>
+  </template>
+  <script src="gr-shell-command.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
new file mode 100644
index 0000000..2c546cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-shell-command',
+
+    properties: {
+      command: String,
+      label: String,
+    },
+
+    focusOnCopy() {
+      this.$$('gr-copy-clipboard').focusOnCopy();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
new file mode 100644
index 0000000..a49f76f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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-shell-command</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="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-shell-command.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-shell-command></gr-shell-command>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-shell-command tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('focusOnCopy', () => {
+      const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+          'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/embed/embed.html b/polygerrit-ui/app/embed/embed.html
index 9fb5c23..be4e2f3 100644
--- a/polygerrit-ui/app/embed/embed.html
+++ b/polygerrit-ui/app/embed/embed.html
@@ -14,6 +14,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+<script>
+  // Needed for JSCompiler to understand it's global.
+  // eslint-disable-next-line no-unused-vars, prefer-const
+  let Gerrit = window.Gerrit || {};
+  window.Gerrit = Gerrit;
+</script>
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../elements/change/gr-change-view/gr-change-view.html">
 <link rel="import" href="../elements/core/gr-search-bar/gr-search-bar.html">
diff --git a/polygerrit-ui/app/externs/BUILD b/polygerrit-ui/app/externs/BUILD
new file mode 100644
index 0000000..fab3954
--- /dev/null
+++ b/polygerrit-ui/app/externs/BUILD
@@ -0,0 +1,25 @@
+# Copyright (C) 2018 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(
+    default_visibility = ["//visibility:public"],
+)
+
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
+
+closure_js_library(
+    name = "plugin",
+    srcs = ["plugin.js"],
+    no_closure_library = True,
+)
diff --git a/polygerrit-ui/app/externs/plugin.js b/polygerrit-ui/app/externs/plugin.js
new file mode 100644
index 0000000..c88c724
--- /dev/null
+++ b/polygerrit-ui/app/externs/plugin.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+
+/**
+ * @fileoverview Closure compiler externs for the Gerrit UI plugins.
+ * @externs
+ */
+
+/* eslint-disable no-var */
+
+var Gerrit = {};
+
+/**
+ * @param {!Function} callback
+ */
+Gerrit.install = function(callback) {};
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.html b/polygerrit-ui/app/gr-diff/gr-diff-root.html
new file mode 100644
index 0000000..132654c
--- /dev/null
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.html
@@ -0,0 +1,7 @@
+<script>
+  // Needed for JSCompiler to understand it's global.
+  // eslint-disable-next-line no-unused-vars, prefer-const
+  let Gerrit = window.Gerrit || {};
+  window.Gerrit = Gerrit;
+</script>
+<link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 199a947..8bf104a 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,107 +1,107 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library", "closure_js_binary")
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
 load(
     "//tools/bzl:js.bzl",
-    "vulcanize",
     "bower_component",
+    "bundle_assets",
     "js_component",
 )
 
 def polygerrit_bundle(name, srcs, outs, app):
-  appName = app.split(".html")[0].split("/").pop() # eg: gr-app
+    appName = app.split(".html")[0].split("/").pop()  # eg: gr-app
 
-  closure_js_binary(
-    name = name + "_closure_bin",
-    # Known issue: Closure compilation not compatible with Polymer behaviors.
-    # See: https://github.com/google/closure-compiler/issues/2042
-    compilation_level = "WHITESPACE_ONLY",
-    defs = [
-      "--polymer_version=1",
-      "--jscomp_off=duplicate",
-      "--force_inject_library=es6_runtime",
-    ],
-    language = "ECMASCRIPT5",
-    deps = [name + "_closure_lib"],
-  )
+    closure_js_binary(
+        name = name + "_closure_bin",
+        # Known issue: Closure compilation not compatible with Polymer behaviors.
+        # See: https://github.com/google/closure-compiler/issues/2042
+        compilation_level = "WHITESPACE_ONLY",
+        defs = [
+            "--polymer_version=1",
+            "--jscomp_off=duplicate",
+            "--force_inject_library=es6_runtime",
+        ],
+        language = "ECMASCRIPT5",
+        deps = [name + "_closure_lib"],
+    )
 
-  closure_js_library(
-    name = name + "_closure_lib",
-    srcs = [appName + ".js"],
-    convention = "GOOGLE",
-    # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
-    # and remove this supression
-    suppress = [
-       "JSC_JSDOC_MISSING_TYPE_WARNING",
-       "JSC_UNNECESSARY_ESCAPE",
-       "JSC_UNUSED_LOCAL_ASSIGNMENT",
-    ],
-    deps = [
-      "//lib/polymer_externs:polymer_closure",
-      "@io_bazel_rules_closure//closure/library",
-    ],
-  )
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = [appName + ".js"],
+        convention = "GOOGLE",
+        # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
+        # and remove this supression
+        suppress = [
+            "JSC_JSDOC_MISSING_TYPE_WARNING",
+            "JSC_UNNECESSARY_ESCAPE",
+            "JSC_UNUSED_LOCAL_ASSIGNMENT",
+        ],
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "@io_bazel_rules_closure//closure/library",
+        ],
+    )
 
-  vulcanize(
-    name = appName,
-    srcs = srcs,
-    app = app,
-    deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
-  )
+    bundle_assets(
+        name = appName,
+        srcs = srcs,
+        app = app,
+        deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
+    )
 
-  native.filegroup(
-    name = name + "_app_sources",
-    srcs = [
-      name + "_closure_bin.js",
-      appName + ".html",
-    ],
-  )
+    native.filegroup(
+        name = name + "_app_sources",
+        srcs = [
+            name + "_closure_bin.js",
+            appName + ".html",
+        ],
+    )
 
-  native.filegroup(
-    name = name + "_css_sources",
-    srcs = native.glob(["styles/**/*.css"]),
-  )
+    native.filegroup(
+        name = name + "_css_sources",
+        srcs = native.glob(["styles/**/*.css"]),
+    )
 
-  native.filegroup(
-    name = name + "_theme_sources",
-    srcs = native.glob(
-      ["styles/themes/*.html"],
-      # app-theme.html already included via an import in gr-app.html.
-      exclude = ["styles/themes/app-theme.html"],
-    ),
-  )
+    native.filegroup(
+        name = name + "_theme_sources",
+        srcs = native.glob(
+            ["styles/themes/*.html"],
+            # app-theme.html already included via an import in gr-app.html.
+            exclude = ["styles/themes/app-theme.html"],
+        ),
+    )
 
-  native.filegroup(
-    name = name + "_top_sources",
-    srcs = [
-        "favicon.ico",
-    ],
-  )
+    native.filegroup(
+        name = name + "_top_sources",
+        srcs = [
+            "favicon.ico",
+        ],
+    )
 
-  genrule2(
-    name = name,
-    srcs = [
-      name + "_app_sources",
-      name + "_css_sources",
-      name + "_theme_sources",
-      name + "_top_sources",
-      "//lib/fonts:robotofonts",
-      "//lib/js:highlightjs_files",
-      # we extract from the zip, but depend on the component for license checking.
-      "@webcomponentsjs//:zipfile",
-      "//lib/js:webcomponentsjs"
-    ],
-    outs = outs,
-    cmd = " && ".join([
-      "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
-      "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/"  + appName + ".$$ext; done",
-      "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
-      "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
-      "for f in $(locations "+ name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
-      "for f in $(locations "+ name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
-      "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
-      "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
-      "cd $$TMP",
-      "find . -exec touch -t 198001010000 '{}' ';'",
-      "zip -qr $$ROOT/$@ *",
-    ]),
-  )
+    genrule2(
+        name = name,
+        srcs = [
+            name + "_app_sources",
+            name + "_css_sources",
+            name + "_theme_sources",
+            name + "_top_sources",
+            "//lib/fonts:robotofonts",
+            "//lib/js:highlightjs_files",
+            # we extract from the zip, but depend on the component for license checking.
+            "@webcomponentsjs//:zipfile",
+            "//lib/js:webcomponentsjs",
+        ],
+        outs = outs,
+        cmd = " && ".join([
+            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+            "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + appName + ".$$ext; done",
+            "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
+            "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
+            "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+            "for f in $(locations " + name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
+            "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+            "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
+            "cd $$TMP",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -qr $$ROOT/$@ *",
+        ]),
+    )
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
index a88f68c..b82bf3a 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -39,7 +39,7 @@
       }
       .info > div > span {
         display: inline-block;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         text-align: right;
         width: 4em;
       }
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 6d7469b..37e8bdc 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -116,7 +116,7 @@
       .updated,
       .size,
       .status,
-      .project {
+      .repo {
         white-space: nowrap;
       }
       .star {
@@ -136,7 +136,7 @@
       .topHeader .label {
         border: none;
       }
-      .truncatedProject {
+      .truncatedRepo {
         display: none;
       }
       @media only screen and (max-width: 150em) {
@@ -147,10 +147,10 @@
           max-width: 18rem;
           text-overflow: ellipsis;
         }
-        .truncatedProject {
+        .truncatedRepo {
           display: inline-block;
         }
-        .fullProject {
+        .fullRepo {
           display: none;
         }
       }
@@ -186,7 +186,7 @@
         .topHeader,
         .leftPadding,
         .status,
-        .project,
+        .repo,
         .branch,
         .updated,
         .label,
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.html b/polygerrit-ui/app/styles/gr-voting-styles.html
index 96c8026..3b1ee64 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.html
+++ b/polygerrit-ui/app/styles/gr-voting-styles.html
@@ -21,9 +21,9 @@
       :host {
         --vote-chip-styles: {
           border: 1px solid rgba(0,0,0,.12);
-          border-radius: 12px;
+          border-radius: 1em;
           box-shadow: none;
-          min-width: 40px;
+          min-width: 3em;
         }
       }
     </style>
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 99c4423..0c798f4 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -92,7 +92,7 @@
         display: none !important;
       }
       .separator {
-        border-left: 1px solid var(--deemphasized-text-color);
+        border-left: 1px solid var(--border-color);
         height: 20px;
         margin: 0 8px;
       }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
index 21db329..ea81796 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ b/polygerrit-ui/app/styles/themes/app-theme.html
@@ -76,6 +76,9 @@
   --vote-color-disliked: #f7c4cb;
   --vote-color-neutral: #ebf5fb;
 
+  --vote-text-color-recommended: #388E3C;
+  --vote-text-color-disliked: #D32F2F;
+
   /* Diff colors */
   --diff-selection-background-color: #c7dbf9;
   --light-remove-highlight-color: #FFEBEE;
@@ -93,6 +96,9 @@
   --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
   --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
 
+  --shell-command-background-color: #f5f5f5;
+  --shell-command-decoration-background-color: #ebebeb;
+
   --comment-text-color: #000;
   --comment-background-color: #fcfad6;
   --unresolved-comment-background-color: #fcfaa6;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 1f473da..8ade9ba 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -29,6 +29,7 @@
       --diff-selection-background-color: #3A71D8;
       --light-remove-highlight-color: rgb(53, 27, 27);
       --light-add-highlight-color: rgb(24, 45, 24);
+      --light-remove-add-highlight-color: #2f3f2f;
       --light-rebased-remove-highlight-color: rgb(60, 37, 8);
       --light-rebased-add-highlight-color: rgb(72, 113, 101);
       --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
@@ -39,6 +40,8 @@
       --diff-context-control-border-color: var(--border-color);
       --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
       --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --shell-command-background-color: #5f5f5f;
+      --shell-command-decoration-background-color: #999999;
       --comment-text-color: var(--primary-text-color);
       --comment-background-color: #0B162B;
       --unresolved-comment-background-color: rgb(56, 90, 154);
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index 6b3a4d1..92b99e3 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -35,7 +35,7 @@
   });
 </script>
 <script>
-  // eslint-disable-next-line no-unused-vars
+  /* eslint-disable no-unused-vars */
   const mockPromise = () => {
     let res;
     const promise = new Promise(resolve => {
@@ -44,6 +44,8 @@
     promise.resolve = res;
     return promise;
   };
+  const isHidden = el => getComputedStyle(el).display === 'none';
+  /* eslint-enable no-unused-vars */
 </script>
 <script>
   (function() {
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 5a5dbcd..816700b 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -63,6 +63,7 @@
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
+    'change/gr-change-requirements/gr-change-requirements_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
@@ -71,6 +72,7 @@
     'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
+    'change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
     'change/gr-file-list-header/gr-file-list-header_test.html',
     'change/gr-file-list/gr-file-list_test.html',
@@ -84,7 +86,9 @@
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
     'change/gr-thread-list/gr-thread-list_test.html',
+    'change/gr-upload-help-dialog/gr-upload-help-dialog_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
+    'core/gr-error-dialog/gr-error-dialog_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
@@ -149,10 +153,10 @@
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-change-status/gr-change-status_test.html',
-    'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
     'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
+    'shared/gr-dialog/gr-dialog_test.html',
     'shared/gr-download-commands/gr-download-commands_test.html',
     'shared/gr-dropdown-list/gr-dropdown-list_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
@@ -164,12 +168,14 @@
     'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
     'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
     'shared/gr-fixed-panel/gr-fixed-panel_test.html',
+    'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
     'shared/gr-lib-loader/gr-lib-loader_test.html',
     'shared/gr-limited-text/gr-limited-text_test.html',
     'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-list-view/gr-list-view_test.html',
     'shared/gr-page-nav/gr-page-nav_test.html',
+    'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
     'shared/gr-rest-api-interface/gr-auth_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
@@ -203,6 +209,8 @@
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+    'gr-url-encoding-behavior/gr-url-encoding-behavior_test.html',
+    'safe-types-behavior/safe-types-behavior_test.html',
   ];
   /* eslint-enable max-len */
   for (let file of behaviors) {
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2594ff7..f44f4d7 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -20,7 +20,6 @@
 	"encoding/json"
 	"errors"
 	"flag"
-	"github.com/robfig/soy"
 	"io"
 	"io/ioutil"
 	"log"
@@ -29,6 +28,8 @@
 	"net/url"
 	"regexp"
 	"strings"
+
+	"github.com/robfig/soy"
 )
 
 var (
@@ -80,13 +81,6 @@
 }
 
 func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
-	if strings.HasSuffix(r.URL.Path, ".html") {
-		w.Header().Set("Content-Type", "text/html")
-	} else if strings.HasSuffix(r.URL.Path, ".css") {
-		w.Header().Set("Content-Type", "text/css")
-	} else {
-		w.Header().Set("Content-Type", "application/json")
-	}
 	req := &http.Request{
 		Method: "GET",
 		URL: &url.URL{
@@ -102,6 +96,13 @@
 		return
 	}
 	defer res.Body.Close()
+	for name, values := range res.Header {
+		for _, value := range values {
+			if name != "Content-Length" {
+				w.Header().Add(name, value)
+			}
+		}
+	}
 	w.WriteHeader(res.StatusCode)
 	if _, err := io.Copy(w, patchResponse(r, res)); err != nil {
 		log.Println("Error copying response to ResponseWriter:", err)
diff --git a/proto/BUILD b/proto/BUILD
index 4528dcb..00d725a 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -8,3 +8,24 @@
     visibility = ["//visibility:public"],
     deps = [":cache_proto"],
 )
+
+genrule(
+    name = "gen_reviewdb_proto",
+    outs = ["reviewdb.proto"],
+    cmd = "$(location //java/com/google/gerrit/proto:ProtoGen) -o $@",
+    tools = ["//java/com/google/gerrit/proto:ProtoGen"],
+)
+
+proto_library(
+    name = "reviewdb_proto",
+    srcs = [":reviewdb.proto"],
+)
+
+java_proto_library(
+    name = "reviewdb_java_proto",
+    visibility = [
+        "//javatests/com/google/gerrit/proto:__pkg__",
+        "//tools/eclipse:__pkg__",
+    ],
+    deps = [":reviewdb_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
index a826f8c..c2ac0d9 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -193,3 +193,44 @@
   string submit_type = 3;
   bool content_merge = 4;
 }
+
+// Serialized form of com.google.gerrit.server.query.git.TagSetHolder.
+// Next ID: 3
+message TagSetHolderProto {
+  string project_name = 1;
+
+  // Next ID: 4
+  message TagSetProto {
+    string project_name = 1;
+
+    // Next ID: 3
+    message CachedRefProto {
+      bytes id = 1;
+      int32 flag = 2;
+    }
+    map<string, CachedRefProto> ref = 2;
+
+    // Next ID: 3
+    message TagProto {
+      bytes id = 1;
+      bytes flags = 2;
+    }
+    repeated TagProto tag = 3;
+  }
+  TagSetProto tags = 2;
+}
+
+// Serialized form of
+// com.google.gerrit.server.account.externalids.AllExternalIds.
+// Next ID: 2
+message AllExternalIdsProto {
+  // Next ID: 6
+  message ExternalIdProto {
+    string key = 1;
+    int32 accountId = 2;
+    string email = 3;
+    string password = 4;
+    bytes blobId = 5;
+  }
+  repeated ExternalIdProto external_id = 1;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 3dd6360..f401735 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,6 +19,8 @@
 /**
  * @param canonicalPath
  * @param staticResourcePath
+ * @param? assetsPath {string} URL to static assets root, if served from CDN.
+ * @param? assetsBundle {string} Assets bundle .html file, served from $assetsPath.
  * @param? faviconPath
  * @param? versionInfo
  * @param? deprecateGwtUi
@@ -36,6 +38,7 @@
     {if $deprecateGwtUi}window.DEPRECATE_GWT_UI = true;{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
+    {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -46,12 +49,12 @@
 
   // RobotoMono fonts are used in styles/fonts.css
   // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
@@ -61,6 +64,10 @@
   // CC them on any changes that load content before gr-app.html.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
+  {if $assetsPath and $assetsBundle}
+    <link rel="import" href="{$assetsPath + $assetsBundle}">{\n}
+  {/if}
+
   <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
   <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
 
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index 5571e7c..d3f3666 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -443,6 +443,11 @@
                 echo -16 > "/proc/${PID}/oom_adj"
             fi
         fi
+    elif [ "$(uname -s)"=="Linux" ] && test -d "/proc/${PID}"; then
+        echo "WARNING: Could not adjust Gerrit's process for the kernel's out-of-memory killer."
+        echo "         This may be caused by ${0} not being run as root."
+        echo "         Consider changing the OOM score adjustment manually for Gerrit's PID=${PID} with e.g.:"
+        echo "         echo '-1000' | sudo tee /proc/${PID}/oom_score_adj"
     fi
 
     TIMEOUT="$GERRIT_STARTUP_TIMEOUT"
diff --git a/resources/com/google/gerrit/proto/BUILD b/resources/com/google/gerrit/proto/BUILD
new file mode 100644
index 0000000..ec2e05c
--- /dev/null
+++ b/resources/com/google/gerrit/proto/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "proto",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/resources/com/google/gerrit/proto/ProtoGenHeader.txt
similarity index 95%
rename from resources/com/google/gerrit/pgm/ProtoGenHeader.txt
rename to resources/com/google/gerrit/proto/ProtoGenHeader.txt
index a380955..8797790 100644
--- a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
+++ b/resources/com/google/gerrit/proto/ProtoGenHeader.txt
@@ -14,7 +14,6 @@
 
 syntax = "proto2";
 
-option java_api_version = 2;
 option java_package = "com.google.gerrit.proto.reviewdb";
 
 package devtools.gerritcodereview;
diff --git a/resources/com/google/gerrit/server/BUILD b/resources/com/google/gerrit/server/BUILD
index 688474e..e92c4e1 100644
--- a/resources/com/google/gerrit/server/BUILD
+++ b/resources/com/google/gerrit/server/BUILD
@@ -6,3 +6,9 @@
     ),
     visibility = ["//visibility:public"],
 )
+
+sh_test(
+    name = "commit-msg_test",
+    srcs = ["commit-msg_test.sh"],
+    data = [":server"],
+)
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
new file mode 100755
index 0000000..99bf509
--- /dev/null
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+set -eu
+
+hook=$(pwd)/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+
+cd $TEST_TMPDIR
+
+function fail {
+  echo "FAIL: $1"
+  exit 1
+}
+
+function test_nonexistent_argument {
+  rm -f input
+  if ${hook} input ; then
+    fail "must fail for non-existent input"
+  fi
+}
+
+function test_empty {
+  rm -f input
+  touch input
+  if ${hook} input ; then
+    fail "must fail on empty message"
+  fi
+}
+
+# a Change-Id already set is preserved.
+function test_preserve_changeid {
+  cat << EOF > input
+bla bla
+
+Change-Id: I123
+EOF
+
+  ${hook} input || fail "failed hook execution"
+
+  found=$(grep -c '^Change-Id' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+  found=$(grep -c '^Change-Id: I123' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Id: I123, want 1"
+  fi
+}
+
+# Change-Id goes after existing trailers.
+function test_at_end {
+  cat << EOF > input
+bla bla
+
+Bug: #123
+EOF
+
+  ${hook} input || fail "failed hook execution"
+  result=$(tail -1 input | grep ^Change-Id)
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id at end"
+  fi
+}
+
+function test_dash_at_end {
+  if [[ ! -x /bin/dash ]] ; then
+    echo "/bin/dash not installed; skipping dash test."
+    return
+  fi
+
+  cat << EOF > input
+bla bla
+
+Bug: #123
+EOF
+
+  /bin/dash ${hook} input || fail "failed hook execution"
+
+  result=$(tail -1 input | grep ^Change-Id)
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id at end"
+  fi
+}
+
+function test_preserve_dash_changeid {
+  if [[ ! -x /bin/dash ]] ; then
+    echo "/bin/dash not installed; skipping dash test."
+    return
+  fi
+
+  cat << EOF > input
+bla bla
+
+Change-Id: I123
+EOF
+
+  /bin/dash ${hook} input || fail "failed hook execution"
+
+  found=$(grep -c '^Change-Id' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+  found=$(grep -c '^Change-Id: I123' input)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Id: I123, want 1"
+  fi
+}
+
+
+# Test driver.
+
+for func in $( declare -F | awk '{print $3;}' | sort); do
+  case ${func} in
+    test_*)
+      echo "=== testing $func"
+      ${func}
+      echo "--- done    $func"
+      ;;
+  esac
+done
diff --git a/resources/com/google/gerrit/server/documentation/pegdown.css b/resources/com/google/gerrit/server/documentation/flexmark-java.css
similarity index 100%
rename from resources/com/google/gerrit/server/documentation/pegdown.css
rename to resources/com/google/gerrit/server/documentation/flexmark-java.css
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 3170448..f9a11cd 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -39,7 +39,9 @@
   {/if}
 
   {for $group in $commentFiles}
-    {$group.link}{\n}
+    // Insert a space before the newline so that Gmail does not mistakenly link
+    // the following line with the file link. See issue 9201.
+    {$group.link}{sp}{\n}
     {$group.title}:{\n}
     {\n}
 
@@ -53,7 +55,12 @@
         {if isFirst($line)}
           {if $comment.startLine != 0}
             {$comment.link}
-          {/if}{\n}
+          {/if}
+
+          // Insert a space before the newline so that Gmail does not mistakenly
+          // link the following line with the file link. See issue 9201.
+          {sp}{\n}
+
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 996e8a4..5f5979d 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -83,6 +83,7 @@
 gss = text/x-gss
 h = text/x-csrc
 haml = text/x-haml
+handlebars = text/x-handlebars
 hh = text/x-c++src
 history.md = text/x-gfm
 hpp = text/x-c++src
@@ -123,6 +124,7 @@
 mbox = application/mbox
 md = text/x-markdown
 mirc = text/mirc
+mjs = text/x-mjs
 mkd = text/x-markdown
 ml = text/x-ocaml
 mli = text/x-ocaml
@@ -145,11 +147,13 @@
 p = text/x-pascal
 pas = text/x-pascal
 patch = text/x-diff
+pcss = text/x-pcss
 pgp = application/pgp
 php = text/x-php
 php3 = text/x-php
 php4 = text/x-php
 php5 = text/x-php
+php7 = text/x-php
 phtml = text/x-php
 pig = text/x-pig
 pl = text/x-perl
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
old mode 100644
new mode 100755
index 4c64559..738e660
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -15,179 +15,29 @@
 # 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.
-#
 
-unset GREP_OPTIONS
+# avoid [[ which is not POSIX sh.
+if test "$#" != 1 ; then
+  echo "$0 requires an argument."
+  exit 1
+fi
 
-CHANGE_ID_AFTER="Bug|Depends-On|Issue|Test|Feature|Fixes|Fixed"
-MSG="$1"
+if test ! -f "$1" ; then
+  echo "file does not exist: $1"
+  exit 1
+fi
 
-# Check for, and add if missing, a unique Change-Id
-#
-add_ChangeId() {
-	clean_message=`sed -e '
-		/^diff --git .*/{
-			s///
-			q
-		}
-		/^Signed-off-by:/d
-		/^#/d
-	' "$MSG" | git stripspace`
-	if test -z "$clean_message"
-	then
-		return
-	fi
+if test ! -s "$1" ; then
+  echo "file is empty: $1"
+  exit 1
+fi
 
-	# Do not add Change-Id to temp commits
-	if echo "$clean_message" | head -1 | grep -q '^\(fixup\|squash\)!'
-	then
-		return
-	fi
+# $RANDOM will be undefined if not using bash, so don't use set -u
+random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+dest="$1.tmp.${random}"
 
-	if test "false" = "`git config --bool --get gerrit.createChangeId`"
-	then
-		return
-	fi
-
-	# Does Change-Id: already exist? if so, exit (no change).
-	if grep -i '^Change-Id:' "$MSG" >/dev/null
-	then
-		return
-	fi
-
-	id=`_gen_ChangeId`
-	T="$MSG.tmp.$$"
-	AWK=awk
-	if [ -x /usr/xpg4/bin/awk ]; then
-		# Solaris AWK is just too broken
-		AWK=/usr/xpg4/bin/awk
-	fi
-
-	# Get core.commentChar from git config or use default symbol
-	commentChar=`git config --get core.commentChar`
-	commentChar=${commentChar:-#}
-
-	# How this works:
-	# - parse the commit message as (textLine+ blankLine*)*
-	# - assume textLine+ to be a footer until proven otherwise
-	# - exception: the first block is not footer (as it is the title)
-	# - read textLine+ into a variable
-	# - then count blankLines
-	# - once the next textLine appears, print textLine+ blankLine* as these
-	#   aren't footer
-	# - in END, the last textLine+ block is available for footer parsing
-	$AWK '
-	BEGIN {
-		if (match(ENVIRON["OS"], "Windows")) {
-			RS="\r?\n" # Required on recent Cygwin
-		}
-		# while we start with the assumption that textLine+
-		# is a footer, the first block is not.
-		isFooter = 0
-		footerComment = 0
-		blankLines = 0
-	}
-
-	# Skip lines starting with commentChar without any spaces before it.
-	/^'"$commentChar"'/ { next }
-
-	# Skip the line starting with the diff command and everything after it,
-	# up to the end of the file, assuming it is only patch data.
-	# If more than one line before the diff was empty, strip all but one.
-	/^diff --git / {
-		blankLines = 0
-		while (getline) { }
-		next
-	}
-
-	# Count blank lines outside footer comments
-	/^$/ && (footerComment == 0) {
-		blankLines++
-		next
-	}
-
-	# Catch footer comment
-	/^\[[a-zA-Z0-9-]+:/ && (isFooter == 1) {
-		footerComment = 1
-	}
-
-	/]$/ && (footerComment == 1) {
-		footerComment = 2
-	}
-
-	# We have a non-blank line after blank lines. Handle this.
-	(blankLines > 0) {
-		print lines
-		for (i = 0; i < blankLines; i++) {
-			print ""
-		}
-
-		lines = ""
-		blankLines = 0
-		isFooter = 1
-		footerComment = 0
-	}
-
-	# Detect that the current block is not the footer
-	(footerComment == 0) && (!/^\[?[a-zA-Z0-9-]+:/ || /^[a-zA-Z0-9-]+:\/\//) {
-		isFooter = 0
-	}
-
-	{
-		# We need this information about the current last comment line
-		if (footerComment == 2) {
-			footerComment = 0
-		}
-		if (lines != "") {
-			lines = lines "\n";
-		}
-		lines = lines $0
-	}
-
-	# Footer handling:
-	# If the last block is considered a footer, splice in the Change-Id at the
-	# right place.
-	# Look for the right place to inject Change-Id by considering
-	# CHANGE_ID_AFTER. Keys listed in it (case insensitive) come first,
-	# then Change-Id, then everything else (eg. Signed-off-by:).
-	#
-	# Otherwise just print the last block, a new line and the Change-Id as a
-	# block of its own.
-	END {
-		unprinted = 1
-		if (isFooter == 0) {
-			print lines "\n"
-			lines = ""
-		}
-		changeIdAfter = "^(" tolower("'"$CHANGE_ID_AFTER"'") "):"
-		numlines = split(lines, footer, "\n")
-		for (line = 1; line <= numlines; line++) {
-			if (unprinted && match(tolower(footer[line]), changeIdAfter) != 1) {
-				unprinted = 0
-				print "Change-Id: I'"$id"'"
-			}
-			print footer[line]
-		}
-		if (unprinted) {
-			print "Change-Id: I'"$id"'"
-		}
-	}' "$MSG" > "$T" && mv "$T" "$MSG" || rm -f "$T"
-}
-_gen_ChangeIdInput() {
-	echo "tree `git write-tree`"
-	if parent=`git rev-parse "HEAD^0" 2>/dev/null`
-	then
-		echo "parent $parent"
-	fi
-	echo "author `git var GIT_AUTHOR_IDENT`"
-	echo "committer `git var GIT_COMMITTER_IDENT`"
-	echo
-	printf '%s' "$clean_message"
-}
-_gen_ChangeId() {
-	_gen_ChangeIdInput |
-	git hash-object -t commit --stdin
-}
-
-
-add_ChangeId
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --if-exists option which only appeared in Git 2.15
+cat "$1" \
+  | git -c trailer.ifexists=doNothing interpret-trailers --trailer "Change-Id: I${random}" > "${dest}" \
+  && mv "${dest}" "$1"
diff --git a/tools/BUILD b/tools/BUILD
index 060cbd8..c368eed 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,6 +1,98 @@
+load(
+    "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
+    "JDK9_JVM_OPTS",
+    "default_java_toolchain",
+)
+
 py_binary(
     name = "merge_jars",
     srcs = ["merge_jars.py"],
     main = "merge_jars.py",
     visibility = ["//visibility:public"],
 )
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath9.jar"],
+    jvm_opts = JDK9_JVM_OPTS,
+    package_configuration = [
+        ":error_prone",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+# This EP warnings list is based on:
+# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+java_package_configuration(
+    name = "error_prone",
+    javacopts = [
+        "-XepDisableWarningsInGeneratedCode",
+        "-Xep:MissingCasesInEnumSwitch:ERROR",
+        "-Xep:ReferenceEquality:WARN",
+        "-Xep:StringEquality:WARN",
+        "-Xep:WildcardImport:WARN",
+        "-Xep:AmbiguousMethodReference:WARN",
+        "-Xep:BadAnnotationImplementation:WARN",
+        "-Xep:BadComparable:WARN",
+        "-Xep:BoxedPrimitiveConstructor:ERROR",
+        "-Xep:CannotMockFinalClass:WARN",
+        "-Xep:ClassCanBeStatic:WARN",
+        "-Xep:ClassNewInstance:WARN",
+        "-Xep:DefaultCharset:ERROR",
+        "-Xep:DoubleCheckedLocking:WARN",
+        "-Xep:ElementsCountedInLoop:WARN",
+        "-Xep:EqualsHashCode:WARN",
+        "-Xep:EqualsIncompatibleType:WARN",
+        "-Xep:ExpectedExceptionChecker:ERROR",
+        "-Xep:Finally:WARN",
+        "-Xep:FloatingPointLiteralPrecision:WARN",
+        "-Xep:FragmentInjection:WARN",
+        "-Xep:FragmentNotInstantiable:WARN",
+        "-Xep:FunctionalInterfaceClash:WARN",
+        "-Xep:FutureReturnValueIgnored:WARN",
+        "-Xep:GetClassOnEnum:WARN",
+        "-Xep:ImmutableAnnotationChecker:WARN",
+        "-Xep:ImmutableEnumChecker:WARN",
+        "-Xep:IncompatibleModifiers:WARN",
+        "-Xep:InjectOnConstructorOfAbstractClass:WARN",
+        "-Xep:InputStreamSlowMultibyteRead:WARN",
+        "-Xep:IterableAndIterator:WARN",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:WARN",
+        "-Xep:JUnitAmbiguousTestClass:WARN",
+        "-Xep:LiteralClassName:WARN",
+        "-Xep:MissingFail:WARN",
+        "-Xep:MissingOverride:WARN",
+        "-Xep:MutableConstantField:WARN",
+        "-Xep:NarrowingCompoundAssignment:WARN",
+        "-Xep:NonAtomicVolatileUpdate:WARN",
+        "-Xep:NonOverridingEquals:WARN",
+        "-Xep:NullableConstructor:WARN",
+        "-Xep:NullablePrimitive:WARN",
+        "-Xep:NullableVoid:WARN",
+        "-Xep:OperatorPrecedence:WARN",
+        "-Xep:OverridesGuiceInjectableMethod:WARN",
+        "-Xep:PreconditionsInvalidPlaceholder:WARN",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:WARN",
+        "-Xep:ProtocolBufferOrdinal:WARN",
+        "-Xep:RequiredModifiers:WARN",
+        "-Xep:ShortCircuitBoolean:WARN",
+        "-Xep:SimpleDateFormatConstant:WARN",
+        "-Xep:StaticGuardedByInstance:WARN",
+        "-Xep:SynchronizeOnNonFinalField:WARN",
+        "-Xep:TruthConstantAsserts:WARN",
+        "-Xep:TypeParameterShadowing:WARN",
+        "-Xep:TypeParameterUnusedInFormals:WARN",
+        "-Xep:URLEqualsHashCode:WARN",
+        "-Xep:UnsynchronizedOverridesSynchronized:WARN",
+        "-Xep:WaitNotInLoop:WARN",
+    ],
+    packages = ["error_prone_packages"],
+)
+
+package_group(
+    name = "error_prone_packages",
+    packages = [
+        "//java/...",
+        "//javatests/...",
+    ],
+)
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index e20624d..97d68d6 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -1,40 +1,43 @@
 def documentation_attributes():
-  return [
-    "toc2",
-    'newline="\\n"',
-    'asterisk="&#42;"',
-    'plus="&#43;"',
-    'caret="&#94;"',
-    'startsb="&#91;"',
-    'endsb="&#93;"',
-    'tilde="&#126;"',
-    "last-update-label!",
-    "source-highlighter=prettify",
-    "stylesheet=DEFAULT",
-    "linkcss=true",
-    "prettifydir=.",
-    # Just a placeholder, will be filled in asciidoctor java binary:
-    "revnumber=%s",
-  ]
+    return [
+        "toc2",
+        'newline="\\n"',
+        'asterisk="&#42;"',
+        'plus="&#43;"',
+        'caret="&#94;"',
+        'startsb="&#91;"',
+        'endsb="&#93;"',
+        'tilde="&#126;"',
+        "last-update-label!",
+        "source-highlighter=prettify",
+        "stylesheet=DEFAULT",
+        "linkcss=true",
+        "prettifydir=.",
+        # Just a placeholder, will be filled in asciidoctor java binary:
+        "revnumber=%s",
+    ]
 
 def _replace_macros_impl(ctx):
-  cmd = [
-    ctx.file._exe.path,
-    '--suffix', ctx.attr.suffix,
-    "-s", ctx.file.src.path,
-    "-o", ctx.outputs.out.path,
-  ]
-  if ctx.attr.searchbox:
-    cmd.append('--searchbox')
-  else:
-    cmd.append('--no-searchbox')
-  ctx.actions.run_shell(
-    inputs = [ctx.file._exe, ctx.file.src],
-    outputs = [ctx.outputs.out],
-    command = cmd,
-    use_default_shell_env = True,
-    progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
-  )
+    cmd = [
+        ctx.file._exe.path,
+        "--suffix",
+        ctx.attr.suffix,
+        "-s",
+        ctx.file.src.path,
+        "-o",
+        ctx.outputs.out.path,
+    ]
+    if ctx.attr.searchbox:
+        cmd.append("--searchbox")
+    else:
+        cmd.append("--no-searchbox")
+    ctx.actions.run_shell(
+        inputs = [ctx.file._exe, ctx.file.src],
+        outputs = [ctx.outputs.out],
+        command = cmd,
+        use_default_shell_env = True,
+        progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
+    )
 
 _replace_macros = rule(
     attrs = {
@@ -54,52 +57,55 @@
 )
 
 def _generate_asciidoc_args(ctx):
-  args = []
-  if ctx.attr.backend:
-    args.extend(["-b", ctx.attr.backend])
-  revnumber = False
-  for attribute in ctx.attr.attributes:
-    if attribute.startswith("revnumber="):
-      revnumber = True
-    else:
-      args.extend(["-a", attribute])
-  if revnumber:
-    args.extend([
-      "--revnumber-file", ctx.file.version.path,
-    ])
-  for src in ctx.files.srcs:
-    args.append(src.path)
-  return args
+    args = []
+    if ctx.attr.backend:
+        args.extend(["-b", ctx.attr.backend])
+    revnumber = False
+    for attribute in ctx.attr.attributes:
+        if attribute.startswith("revnumber="):
+            revnumber = True
+        else:
+            args.extend(["-a", attribute])
+    if revnumber:
+        args.extend([
+            "--revnumber-file",
+            ctx.file.version.path,
+        ])
+    for src in ctx.files.srcs:
+        args.append(src.path)
+    return args
 
 def _invoke_replace_macros(name, src, suffix, searchbox):
-  fn = src
-  if fn.startswith(":"):
-    fn = src[1:]
+    fn = src
+    if fn.startswith(":"):
+        fn = src[1:]
 
-  _replace_macros(
-    name = "macros_%s_%s" % (name, fn),
-    src = src,
-    out = fn + suffix,
-    suffix = suffix,
-    searchbox = searchbox,
-  )
+    _replace_macros(
+        name = "macros_%s_%s" % (name, fn),
+        src = src,
+        out = fn + suffix,
+        suffix = suffix,
+        searchbox = searchbox,
+    )
 
-  return ":" + fn + suffix, fn.replace(".txt", ".html")
+    return ":" + fn + suffix, fn.replace(".txt", ".html")
 
 def _asciidoc_impl(ctx):
-  args = [
-    "--bazel",
-    "--in-ext", ".txt" + ctx.attr.suffix,
-    "--out-ext", ".html",
-  ]
-  args.extend(_generate_asciidoc_args(ctx))
-  ctx.actions.run(
-    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
-    outputs = ctx.outputs.outs,
-    executable = ctx.executable._exe,
-    arguments = args,
-    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
-  )
+    args = [
+        "--bazel",
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
+        outputs = ctx.outputs.outs,
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
 
 _asciidoc_attrs = {
     "_exe": attr.label(
@@ -129,81 +135,85 @@
 )
 
 def _genasciidoc_htmlonly(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    **kwargs):
-  SUFFIX = "." + name + "_macros"
-  new_srcs = []
-  outs = ["asciidoctor.css"]
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_macros"
+    new_srcs = []
+    outs = ["asciidoctor.css"]
 
-  for src in srcs:
-    new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
-    new_srcs.append(new_src)
-    outs.append(html_name)
+    for src in srcs:
+        new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
+        outs.append(html_name)
 
-  _asciidoc(
-    name = name + "_gen",
-    srcs = new_srcs,
-    suffix = SUFFIX,
-    backend = backend,
-    attributes = attributes,
-    outs = outs,
-  )
-
-  native.filegroup(
-    name = name,
-    data = outs,
-    **kwargs
-  )
-
-def genasciidoc(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    resources = True,
-    **kwargs):
-  SUFFIX = "_htmlonly"
-
-  _genasciidoc_htmlonly(
-    name = name + SUFFIX if resources else name,
-    srcs = srcs,
-    attributes = attributes,
-    backend = backend,
-    searchbox = searchbox,
-    **kwargs
-  )
-
-  if resources:
-    htmlonly = ":" + name + SUFFIX
-    native.filegroup(
-      name = name,
-      srcs = [
-        htmlonly,
-        "//Documentation:resources",
-      ],
-      **kwargs
+    _asciidoc(
+        name = name + "_gen",
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+        outs = outs,
     )
 
+    native.filegroup(
+        name = name,
+        data = outs,
+        **kwargs
+    )
+
+def genasciidoc(
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
+
+    _genasciidoc_htmlonly(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
+    )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        native.filegroup(
+            name = name,
+            srcs = [
+                htmlonly,
+                "//Documentation:resources",
+            ],
+            **kwargs
+        )
+
 def _asciidoc_html_zip_impl(ctx):
-  args = [
-    "--mktmp",
-    "-z", ctx.outputs.out.path,
-    "--in-ext", ".txt" + ctx.attr.suffix,
-    "--out-ext", ".html",
-  ]
-  args.extend(_generate_asciidoc_args(ctx))
-  ctx.actions.run(
-    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
-    outputs = [ctx.outputs.out],
-    executable = ctx.executable._exe,
-    arguments = args,
-    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
-  )
+    args = [
+        "--mktmp",
+        "-z",
+        ctx.outputs.out.path,
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.file.version],
+        outputs = [ctx.outputs.out],
+        tools = [ctx.executable._exe],
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
 
 _asciidoc_html_zip = rule(
     attrs = _asciidoc_attrs,
@@ -214,53 +224,54 @@
 )
 
 def _genasciidoc_htmlonly_zip(
-    name,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    **kwargs):
-  SUFFIX = "." + name + "_expn"
-  new_srcs = []
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_expn"
+    new_srcs = []
 
-  for src in srcs:
-    new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
-    new_srcs.append(new_src)
+    for src in srcs:
+        new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
 
-  _asciidoc_html_zip(
-    name = name,
-    srcs = new_srcs,
-    suffix = SUFFIX,
-    backend = backend,
-    attributes = attributes,
-  )
+    _asciidoc_html_zip(
+        name = name,
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+    )
 
 def _asciidoc_zip_impl(ctx):
-  tmpdir = ctx.outputs.out.path + "_tmpdir"
-  cmd = [
-    "p=$PWD",
-    "rm -rf %s" % tmpdir,
-    "mkdir -p %s/%s/" % (tmpdir, ctx.attr.directory),
-    "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
-  ]
-  for r in ctx.files.resources:
-    if r.path == r.short_path:
-      cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
-    else:
-      parent = r.path[:-len(r.short_path)]
-      cmd.append(
-        "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir))
-  cmd.extend([
-    "cd %s" % tmpdir,
-    "zip -qr $p/%s *" % ctx.outputs.out.path,
-  ])
-  ctx.actions.run_shell(
-    inputs = [ctx.file.src] + ctx.files.resources,
-    outputs = [ctx.outputs.out],
-    command = " && ".join(cmd),
-    progress_message =
-        "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
-  )
+    tmpdir = ctx.outputs.out.path + "_tmpdir"
+    cmd = [
+        "p=$PWD",
+        "rm -rf %s" % tmpdir,
+        "mkdir -p %s/%s/" % (tmpdir, ctx.attr.directory),
+        "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
+    ]
+    for r in ctx.files.resources:
+        if r.path == r.short_path:
+            cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
+        else:
+            parent = r.path[:-len(r.short_path)]
+            cmd.append(
+                "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir),
+            )
+    cmd.extend([
+        "cd %s" % tmpdir,
+        "zip -qr $p/%s *" % ctx.outputs.out.path,
+    ])
+    ctx.actions.run_shell(
+        inputs = [ctx.file.src] + ctx.files.resources,
+        outputs = [ctx.outputs.out],
+        command = " && ".join(cmd),
+        progress_message =
+            "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
+    )
 
 _asciidoc_zip = rule(
     attrs = {
@@ -281,30 +292,30 @@
 )
 
 def genasciidoc_zip(
-    name,
-    srcs = [],
-    attributes = [],
-    directory = None,
-    backend = None,
-    searchbox = True,
-    resources = True,
-    **kwargs):
-  SUFFIX = "_htmlonly"
+        name,
+        srcs = [],
+        attributes = [],
+        directory = None,
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
 
-  _genasciidoc_htmlonly_zip(
-    name = name + SUFFIX if resources else name,
-    srcs = srcs,
-    attributes = attributes,
-    backend = backend,
-    searchbox = searchbox,
-    **kwargs
-  )
-
-  if resources:
-    htmlonly = ":" + name + SUFFIX
-    _asciidoc_zip(
-      name = name,
-      src = htmlonly,
-      resources = ["//Documentation:resources"],
-      directory = directory,
+    _genasciidoc_htmlonly_zip(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
     )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        _asciidoc_zip(
+            name = name,
+            src = htmlonly,
+            resources = ["//Documentation:resources"],
+            directory = directory,
+        )
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index 9448ed1..b60e4e4 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -1,15 +1,18 @@
 def _classpath_collector(ctx):
     all = depset()
     for d in ctx.attr.deps:
-        if hasattr(d, 'java'):
+        if hasattr(d, "java"):
             all += d.java.transitive_runtime_deps
-            all += d.java.compilation_info.runtime_classpath
-        elif hasattr(d, 'files'):
+            if hasattr(d.java.compilation_info, "runtime_classpath"):
+                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)))
+    ctx.file_action(
+        output = ctx.outputs.runtime,
+        content = "\n".join(sorted(as_strs)),
+    )
 
 classpath_collector = rule(
     attrs = {
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
index 563a9ef..3113022 100644
--- a/tools/bzl/genrule2.bzl
+++ b/tools/bzl/genrule2.bzl
@@ -17,11 +17,12 @@
 #   expose TMP shell variable
 
 def genrule2(cmd, **kwargs):
-  cmd = ' && '.join([
-    'ROOT=$$PWD',
-    'TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)',
-    '(' + cmd + ')',
-  ])
-  native.genrule(
-    cmd = cmd,
-    **kwargs)
+    cmd = " && ".join([
+        "ROOT=$$PWD",
+        "TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "(" + cmd + ")",
+    ])
+    native.genrule(
+        cmd = cmd,
+        **kwargs
+    )
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index b09a608..b185214 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -15,7 +15,7 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:java.bzl", "java_library2")
 
-jar_filetype = FileType([".jar"])
+jar_filetype = [".jar"]
 
 BROWSERS = [
     "chrome",
@@ -90,115 +90,122 @@
 </module>
 """
 
-def gwt_module(gwt_xml=None, resources=[], srcs=[], **kwargs):
-  if gwt_xml:
-    resources = resources + [gwt_xml]
+def gwt_module(gwt_xml = None, resources = [], srcs = [], **kwargs):
+    if gwt_xml:
+        resources = resources + [gwt_xml]
 
-  java_library2(
-    srcs = srcs,
-    resources = resources,
-    **kwargs)
+    java_library2(
+        srcs = srcs,
+        resources = resources,
+        **kwargs
+    )
 
 def _gwt_user_agent_module(ctx):
-  """Generate user agent specific GWT module."""
-  if not ctx.attr.user_agent:
-    return None
+    """Generate user agent specific GWT module."""
+    if not ctx.attr.user_agent:
+        return None
 
-  ua = ctx.attr.user_agent
-  impl = ua
-  if ua in ALIASES:
-    impl = ALIASES[ua]
+    ua = ctx.attr.user_agent
+    impl = ua
+    if ua in ALIASES:
+        impl = ALIASES[ua]
 
-  # intermediate artifact: user agent speific GWT xml file
-  gwt_user_agent_xml = ctx.new_file(ctx.label.name + "_gwt.xml")
-  ctx.file_action(output = gwt_user_agent_xml,
-                  content=USER_AGENT_XML % (MODULE, impl))
+    # intermediate artifact: user agent speific GWT xml file
+    gwt_user_agent_xml = ctx.new_file(ctx.label.name + "_gwt.xml")
+    ctx.file_action(
+        output = gwt_user_agent_xml,
+        content = USER_AGENT_XML % (MODULE, impl),
+    )
 
-  # intermediate artifact: user agent specific zip with GWT module
-  gwt_user_agent_zip = ctx.new_file(ctx.label.name + "_gwt.zip")
-  gwt = '%s_%s.gwt.xml' % (MODULE.replace('.', '/'), ua)
-  dir = gwt_user_agent_zip.path + ".dir"
-  cmd = " && ".join([
-    "p=$PWD",
-    "mkdir -p %s" % dir,
-    "cd %s" % dir,
-    "mkdir -p $(dirname %s)" % gwt,
-    "cp $p/%s %s" % (gwt_user_agent_xml.path, gwt),
-    "$p/%s cC $p/%s $(find . | sed 's|^./||')" % (ctx.executable._zip.path, gwt_user_agent_zip.path)
-  ])
-  ctx.actions.run_shell(
-    inputs = [gwt_user_agent_xml] + ctx.files._zip,
-    outputs = [gwt_user_agent_zip],
-    command = cmd,
-    mnemonic = "GenerateUserAgentGWTModule")
+    # intermediate artifact: user agent specific zip with GWT module
+    gwt_user_agent_zip = ctx.new_file(ctx.label.name + "_gwt.zip")
+    gwt = "%s_%s.gwt.xml" % (MODULE.replace(".", "/"), ua)
+    dir = gwt_user_agent_zip.path + ".dir"
+    cmd = " && ".join([
+        "p=$PWD",
+        "mkdir -p %s" % dir,
+        "cd %s" % dir,
+        "mkdir -p $(dirname %s)" % gwt,
+        "cp $p/%s %s" % (gwt_user_agent_xml.path, gwt),
+        "$p/%s cC $p/%s $(find . | sed 's|^./||')" % (ctx.executable._zip.path, gwt_user_agent_zip.path),
+    ])
+    ctx.actions.run_shell(
+        inputs = [gwt_user_agent_xml] + ctx.files._zip,
+        outputs = [gwt_user_agent_zip],
+        command = cmd,
+        mnemonic = "GenerateUserAgentGWTModule",
+    )
 
-  return struct(
-    zip=gwt_user_agent_zip,
-    module=MODULE + '_' + ua
-  )
+    return struct(
+        zip = gwt_user_agent_zip,
+        module = MODULE + "_" + ua,
+    )
 
 def _gwt_binary_impl(ctx):
-  module = ctx.attr.module[0]
-  output_zip = ctx.outputs.output
-  output_dir = output_zip.path + '.gwt_output'
-  deploy_dir = output_zip.path + '.gwt_deploy'
+    module = ctx.attr.module[0]
+    output_zip = ctx.outputs.output
+    output_dir = output_zip.path + ".gwt_output"
+    deploy_dir = output_zip.path + ".gwt_deploy"
 
-  deps = _get_transitive_closure(ctx)
+    deps = _get_transitive_closure(ctx)
 
-  paths = []
-  for dep in deps:
-    paths.append(dep.path)
+    paths = []
+    for dep in deps:
+        paths.append(dep.path)
 
-  gwt_user_agent_modules = []
-  ua = _gwt_user_agent_module(ctx)
-  if ua:
-    paths.append(ua.zip.path)
-    gwt_user_agent_modules.append(ua.zip)
-    module = ua.module
+    gwt_user_agent_modules = []
+    ua = _gwt_user_agent_module(ctx)
+    if ua:
+        paths.append(ua.zip.path)
+        gwt_user_agent_modules.append(ua.zip)
+        module = ua.module
 
-  cmd = "external/local_jdk/bin/java %s -Dgwt.normalizeTimestamps=true -cp %s %s -war %s -deploy %s " % (
-    " ".join(ctx.attr.jvm_args),
-    ":".join(paths),
-    GWT_COMPILER,
-    output_dir,
-    deploy_dir,
-  )
-  # TODO(davido): clean up command concatenation
-  cmd += " ".join([
-    "-style %s" % ctx.attr.style,
-    "-optimize %s" % ctx.attr.optimize,
-    "-strict",
-    " ".join(ctx.attr.compiler_args),
-    module + "\n",
-    "rm -rf %s/gwt-unitCache\n" % output_dir,
-    "root=`pwd`\n",
-    "cd %s; $root/%s Cc ../%s $(find .)\n" % (
-      output_dir,
-      ctx.executable._zip.path,
-      output_zip.basename,
+    cmd = "%s %s -Dgwt.normalizeTimestamps=true -cp %s %s -war %s -deploy %s " % (
+        ctx.attr._jdk[java_common.JavaRuntimeInfo].java_executable_exec_path,
+        " ".join(ctx.attr.jvm_args),
+        ":".join(paths),
+        GWT_COMPILER,
+        output_dir,
+        deploy_dir,
     )
-  ])
 
-  ctx.actions.run_shell(
-    inputs = list(deps) + ctx.files._jdk + ctx.files._zip + gwt_user_agent_modules,
-    outputs = [output_zip],
-    mnemonic = "GwtBinary",
-    progress_message = "GWT compiling " + output_zip.short_path,
-    command = "set -e\n" + cmd,
-  )
+    # TODO(davido): clean up command concatenation
+    cmd += " ".join([
+        "-style %s" % ctx.attr.style,
+        "-optimize %s" % ctx.attr.optimize,
+        "-strict",
+        " ".join(ctx.attr.compiler_args),
+        module + "\n",
+        "rm -rf %s/gwt-unitCache\n" % output_dir,
+        "root=`pwd`\n",
+        "cd %s; $root/%s Cc ../%s $(find .)\n" % (
+            output_dir,
+            ctx.executable._zip.path,
+            output_zip.basename,
+        ),
+    ])
+
+    ctx.actions.run_shell(
+        inputs = list(deps) + gwt_user_agent_modules,
+        outputs = [output_zip],
+        tools = ctx.files._jdk + ctx.files._zip,
+        mnemonic = "GwtBinary",
+        progress_message = "GWT compiling " + output_zip.short_path,
+        command = "set -e\n" + cmd,
+    )
 
 def _get_transitive_closure(ctx):
-  deps = depset()
-  for dep in ctx.attr.module_deps:
-    deps += dep.java.transitive_runtime_deps
-    deps += dep.java.transitive_source_jars
-  for dep in ctx.attr.deps:
-    if hasattr(dep, 'java'):
-      deps += dep.java.transitive_runtime_deps
-    elif hasattr(dep, 'files'):
-      deps += dep.files
+    deps = depset()
+    for dep in ctx.attr.module_deps:
+        deps += dep.java.transitive_runtime_deps
+        deps += dep.java.transitive_source_jars
+    for dep in ctx.attr.deps:
+        if hasattr(dep, "java"):
+            deps += dep.java.transitive_runtime_deps
+        elif hasattr(dep, "files"):
+            deps += dep.files
 
-  return deps
+    return deps
 
 gwt_binary = rule(
     attrs = {
@@ -211,13 +218,14 @@
         "compiler_args": attr.string_list(),
         "jvm_args": attr.string_list(),
         "_jdk": attr.label(
-            default = Label("//tools/defaults:jdk"),
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
+            cfg = "host",
         ),
         "_zip": attr.label(
             default = Label("@bazel_tools//tools/zip:zipper"),
             cfg = "host",
             executable = True,
-            single_file = True,
+            allow_single_file = True,
         ),
     },
     outputs = {
@@ -227,77 +235,78 @@
 )
 
 def gwt_genrule(suffix = ""):
-  dbg = 'ui_dbg' + suffix
-  opt = 'ui_opt' + suffix
-  module_dep = ':ui_module' + suffix
-  args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
+    dbg = "ui_dbg" + suffix
+    opt = "ui_opt" + suffix
+    module_dep = ":ui_module" + suffix
+    args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
 
-  genrule2(
-    name = 'ui_optdbg' + suffix,
-    srcs = [
-      ':' + dbg,
-      ':' + opt,
-     ],
-    cmd = 'cd $$TMP;' +
-      'unzip -q $$ROOT/$(location :%s);' % dbg +
-      'mv' +
-      ' gerrit_ui/gerrit_ui.nocache.js' +
-      ' gerrit_ui/dbg_gerrit_ui.nocache.js;' +
-      'unzip -qo $$ROOT/$(location :%s);' % opt +
-      'mkdir -p $$(dirname $@);' +
-      'zip -qrD $$ROOT/$@ .',
-    outs = ['ui_optdbg' + suffix + '.zip'],
-    visibility = ['//visibility:public'],
-   )
+    genrule2(
+        name = "ui_optdbg" + suffix,
+        srcs = [
+            ":" + dbg,
+            ":" + opt,
+        ],
+        cmd = "cd $$TMP;" +
+              "unzip -q $$ROOT/$(location :%s);" % dbg +
+              "mv" +
+              " gerrit_ui/gerrit_ui.nocache.js" +
+              " gerrit_ui/dbg_gerrit_ui.nocache.js;" +
+              "unzip -qo $$ROOT/$(location :%s);" % opt +
+              "mkdir -p $$(dirname $@);" +
+              "zip -qrD $$ROOT/$@ .",
+        outs = ["ui_optdbg" + suffix + ".zip"],
+        visibility = ["//visibility:public"],
+    )
 
-  gwt_binary(
-    name = opt,
-    module = [MODULE],
-    module_deps = [module_dep],
-    deps = DEPS,
-    compiler_args = args,
-    jvm_args = GWT_JVM_ARGS,
-  )
+    gwt_binary(
+        name = opt,
+        module = [MODULE],
+        module_deps = [module_dep],
+        deps = DEPS,
+        compiler_args = args,
+        jvm_args = GWT_JVM_ARGS,
+    )
 
-  gwt_binary(
-    name = dbg,
-    style = 'PRETTY',
-    optimize = "0",
-    module_deps = [module_dep],
-    deps = DEPS,
-    compiler_args = GWT_COMPILER_ARGS,
-    jvm_args = GWT_JVM_ARGS,
-  )
+    gwt_binary(
+        name = dbg,
+        style = "PRETTY",
+        optimize = "0",
+        module_deps = [module_dep],
+        deps = DEPS,
+        compiler_args = GWT_COMPILER_ARGS,
+        jvm_args = GWT_JVM_ARGS,
+    )
 
 def gen_ui_module(name, suffix = ""):
-  gwt_module(
-    name = name + suffix,
-    srcs = native.glob(['src/main/java/**/*.java']),
-    gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
-    resources = native.glob(
-        ['src/main/java/**/*'],
-        exclude = ['src/main/java/**/*.java'] +
-        ['src/main/java/%s.gwt.xml' % MODULE.replace('.', '/')]),
-    deps = [
-      '//gerrit-gwtui-common:diffy_logo',
-      '//gerrit-gwtui-common:client',
-      '//java/com/google/gwtexpui/css',
-      '//lib/codemirror:codemirror' + suffix,
-      '//lib/gwt:user',
-    ],
-    visibility = ['//visibility:public'],
-  )
+    gwt_module(
+        name = name + suffix,
+        srcs = native.glob(["src/main/java/**/*.java"]),
+        gwt_xml = "src/main/java/%s.gwt.xml" % MODULE.replace(".", "/"),
+        resources = native.glob(
+            ["src/main/java/**/*"],
+            exclude = ["src/main/java/**/*.java"] +
+                      ["src/main/java/%s.gwt.xml" % MODULE.replace(".", "/")],
+        ),
+        deps = [
+            "//gerrit-gwtui-common:diffy_logo",
+            "//gerrit-gwtui-common:client",
+            "//java/com/google/gwtexpui/css",
+            "//lib/codemirror:codemirror" + suffix,
+            "//lib/gwt:user",
+        ],
+        visibility = ["//visibility:public"],
+    )
 
 def gwt_user_agent_permutations():
-  for ua in BROWSERS:
-    gwt_binary(
-      name = "ui_%s" % ua,
-      user_agent = ua,
-      style = 'PRETTY',
-      optimize = "0",
-      module = [MODULE],
-      module_deps = [':ui_module'],
-      deps = DEPS,
-      compiler_args = GWT_COMPILER_ARGS,
-      jvm_args = GWT_JVM_ARGS,
-    )
+    for ua in BROWSERS:
+        gwt_binary(
+            name = "ui_%s" % ua,
+            user_agent = ua,
+            style = "PRETTY",
+            optimize = "0",
+            module = [MODULE],
+            module_deps = [":ui_module"],
+            deps = DEPS,
+            compiler_args = GWT_COMPILER_ARGS,
+            jvm_args = GWT_JVM_ARGS,
+        )
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
index 5fca724..7c41fbe 100644
--- a/tools/bzl/java.bzl
+++ b/tools/bzl/java.bzl
@@ -15,11 +15,12 @@
 # Syntactic sugar for native java_library() rule:
 #   accept exported_deps attributes
 
-def java_library2(deps=[], exported_deps=[], exports=[], **kwargs):
-  if exported_deps:
-    deps = deps + exported_deps
-    exports = exports + exported_deps
-  native.java_library(
-    deps = deps,
-    exports = exports,
-    **kwargs)
+def java_library2(deps = [], exported_deps = [], exports = [], **kwargs):
+    if exported_deps:
+        deps = deps + exported_deps
+        exports = exports + exported_deps
+    native.java_library(
+        deps = deps,
+        exports = exports,
+        **kwargs
+    )
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index f49c881..8f2316c 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -15,49 +15,51 @@
 # Javadoc rule.
 
 def _impl(ctx):
-  zip_output = ctx.outputs.zip
+    zip_output = ctx.outputs.zip
 
-  transitive_jar_set = depset()
-  source_jars = depset()
-  for l in ctx.attr.libs:
-    source_jars += l.java.source_jars
-    transitive_jar_set += l.java.transitive_deps
+    transitive_jar_set = depset()
+    source_jars = depset()
+    for l in ctx.attr.libs:
+        source_jars += l.java.source_jars
+        transitive_jar_set += l.java.transitive_deps
 
-  transitive_jar_paths = [j.path for j in transitive_jar_set]
-  dir = ctx.outputs.zip.path + ".dir"
-  source = ctx.outputs.zip.path + ".source"
-  external_docs = ["http://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
-  cmd = [
-      "TZ=UTC",
-      "export TZ",
-      "rm -rf %s" % source,
-      "mkdir %s" % source,
-      " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars]),
-      "rm -rf %s" % dir,
-      "mkdir %s" % dir,
-      " ".join([
-        ctx.file._javadoc.path,
-        "-Xdoclint:-missing",
-        "-protected",
-        "-encoding UTF-8",
-        "-charset UTF-8",
-        "-notimestamp",
-        "-quiet",
-        "-windowtitle '%s'" % ctx.attr.title,
-        " ".join(['-link %s' % url for url in external_docs]),
-        "-sourcepath %s" % source,
-        "-subpackages ",
-        ":".join(ctx.attr.pkgs),
-        " -classpath ",
-        ":".join(transitive_jar_paths),
-        "-d %s" % dir]),
-    "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
-    "(cd %s && zip -Xqr ../%s *)" % (dir, ctx.outputs.zip.basename),
-  ]
-  ctx.actions.run_shell(
-      inputs = list(transitive_jar_set) + list(source_jars) + ctx.files._jdk,
-      outputs = [zip_output],
-      command = " && ".join(cmd))
+    transitive_jar_paths = [j.path for j in transitive_jar_set]
+    dir = ctx.outputs.zip.path + ".dir"
+    source = ctx.outputs.zip.path + ".source"
+    external_docs = ["http://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
+    cmd = [
+        "TZ=UTC",
+        "export TZ",
+        "rm -rf %s" % source,
+        "mkdir %s" % source,
+        " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars]),
+        "rm -rf %s" % dir,
+        "mkdir %s" % dir,
+        " ".join([
+            "%s/bin/javadoc" % ctx.attr._jdk[java_common.JavaRuntimeInfo].java_home,
+            "-Xdoclint:-missing",
+            "-protected",
+            "-encoding UTF-8",
+            "-charset UTF-8",
+            "-notimestamp",
+            "-quiet",
+            "-windowtitle '%s'" % ctx.attr.title,
+            " ".join(["-link %s" % url for url in external_docs]),
+            "-sourcepath %s" % source,
+            "-subpackages ",
+            ":".join(ctx.attr.pkgs),
+            " -classpath ",
+            ":".join(transitive_jar_paths),
+            "-d %s" % dir,
+        ]),
+        "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
+        "(cd %s && zip -Xqr ../%s *)" % (dir, ctx.outputs.zip.basename),
+    ]
+    ctx.actions.run_shell(
+        inputs = list(transitive_jar_set) + list(source_jars) + ctx.files._jdk,
+        outputs = [zip_output],
+        command = " && ".join(cmd),
+    )
 
 java_doc = rule(
     attrs = {
@@ -65,14 +67,10 @@
         "pkgs": attr.string_list(),
         "title": attr.string(),
         "external_docs": attr.string_list(),
-        "_javadoc": attr.label(
-            default = Label("@local_jdk//:bin/javadoc"),
-            single_file = True,
-            allow_files = True,
-        ),
         "_jdk": attr.label(
-            default = Label("@local_jdk//:jdk-default"),
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
             allow_files = True,
+            providers = [java_common.JavaRuntimeInfo],
         ),
     },
     outputs = {"zip": "%{name}.zip"},
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 2796f64..0997bcb 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -2,37 +2,38 @@
 
 GERRIT = "GERRIT:"
 
-load("//lib/js:npm.bzl", "NPM_VERSIONS", "NPM_SHA1S")
+load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
 
 def _npm_tarball(name):
-  return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
+    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
 
 def _npm_binary_impl(ctx):
-  """rule to download a NPM archive."""
-  name = ctx.name
-  version= NPM_VERSIONS[name]
-  sha1 = NPM_SHA1S[name]
+    """rule to download a NPM archive."""
+    name = ctx.name
+    version = NPM_VERSIONS[name]
+    sha1 = NPM_SHA1S[name]
 
-  dir = '%s-%s' % (name, version)
-  filename = '%s.tgz' % dir
-  base =  '%s@%s.npm_binary.tgz' % (name, version)
-  dest = ctx.path(base)
-  repository = ctx.attr.repository
-  if repository == GERRIT:
-    url = 'http://gerrit-maven.storage.googleapis.com/npm-packages/%s' % filename
-  elif repository == NPMJS:
-    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
-  else:
-    fail('repository %s not in {%s,%s}' % (repository, GERRIT, NPMJS))
+    dir = "%s-%s" % (name, version)
+    filename = "%s.tgz" % dir
+    base = "%s@%s.npm_binary.tgz" % (name, version)
+    dest = ctx.path(base)
+    repository = ctx.attr.repository
+    if repository == GERRIT:
+        url = "http://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
+    elif repository == NPMJS:
+        url = "http://registry.npmjs.org/%s/-/%s" % (name, filename)
+    else:
+        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
 
-  python = ctx.which("python")
-  script = ctx.path(ctx.attr._download_script)
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
 
-  args = [python, script, "-o", dest, "-u", url, "-v", sha1]
-  out = ctx.execute(args)
-  if out.return_code:
-    fail("failed %s: %s" % (args, out.stderr))
-  ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
+    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
+    out = ctx.execute(args)
+    if out.return_code:
+        fail("failed %s: %s" % (args, out.stderr))
+    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
 
 npm_binary = repository_rule(
     attrs = {
@@ -46,64 +47,75 @@
 
 # for use in repo rules.
 def _run_npm_binary_str(ctx, tarball, args):
-  python_bin = ctx.which("python")
-  return " ".join([
-    python_bin,
-    ctx.path(ctx.attr._run_npm),
-    ctx.path(tarball)] + args)
+    python_bin = ctx.which("python")
+    return " ".join([
+        python_bin,
+        ctx.path(ctx.attr._run_npm),
+        ctx.path(tarball),
+    ] + args)
 
 def _bower_archive(ctx):
-  """Download a bower package."""
-  download_name = '%s__download_bower.zip' % ctx.name
-  renamed_name = '%s__renamed.zip' % ctx.name
-  version_name = '%s__version.json' % ctx.name
+    """Download a bower package."""
+    download_name = "%s__download_bower.zip" % ctx.name
+    renamed_name = "%s__renamed.zip" % ctx.name
+    version_name = "%s__version.json" % ctx.name
 
-  cmd = [
-      ctx.which("python"),
-      ctx.path(ctx.attr._download_bower),
-      '-b', '%s' % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
-      '-n', ctx.name,
-      '-p', ctx.attr.package,
-      '-v', ctx.attr.version,
-      '-s', ctx.attr.sha1,
-      '-o', download_name,
+    cmd = [
+        ctx.which("python"),
+        ctx.path(ctx.attr._download_bower),
+        "-b",
+        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
+        "-n",
+        ctx.name,
+        "-p",
+        ctx.attr.package,
+        "-v",
+        ctx.attr.version,
+        "-s",
+        ctx.attr.sha1,
+        "-o",
+        download_name,
     ]
 
-  out = ctx.execute(cmd)
-  if out.return_code:
-    fail("failed %s: %s" % (" ".join(cmd), out.stderr))
+    out = ctx.execute(cmd)
+    if out.return_code:
+        fail("failed %s: %s" % (" ".join(cmd), out.stderr))
 
-  _bash(ctx, " && " .join([
-    "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
-    "TZ=UTC",
-    "export UTC",
-    "cd $TMP",
-    "mkdir bower_components",
-    "cd bower_components",
-    "unzip %s" % ctx.path(download_name),
-    "cd ..",
-    "find . -exec touch -t 198001010000 '{}' ';'",
-    "zip -Xr %s bower_components" % renamed_name,
-    "cd ..",
-    "rm -rf ${TMP}",
-  ]))
+    _bash(ctx, " && ".join([
+        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "TZ=UTC",
+        "export UTC",
+        "cd $TMP",
+        "mkdir bower_components",
+        "cd bower_components",
+        "unzip %s" % ctx.path(download_name),
+        "cd ..",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xr %s bower_components" % renamed_name,
+        "cd ..",
+        "rm -rf ${TMP}",
+    ]))
 
-  dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
-  ctx.file(version_name,
-           '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version))
-  ctx.file(
-    "BUILD",
-    "\n".join([
-      "package(default_visibility=['//visibility:public'])",
-      "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
-      "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
-    ]), False)
+    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
+    ctx.file(
+        version_name,
+        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
+    )
+    ctx.file(
+        "BUILD",
+        "\n".join([
+            "package(default_visibility=['//visibility:public'])",
+            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
+            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
+        ]),
+        False,
+    )
 
 def _bash(ctx, cmd):
-  cmd_list = ["bash", "-c", cmd]
-  out = ctx.execute(cmd_list)
-  if out.return_code:
-    fail("failed %s: %s" % (" ".join(cmd_list), out.stderr))
+    cmd_list = ["bash", "-c", cmd]
+    out = ctx.execute(cmd_list)
+    if out.return_code:
+        fail("failed %s: %s" % (" ".join(cmd_list), out.stderr))
 
 bower_archive = repository_rule(
     _bower_archive,
@@ -119,26 +131,26 @@
 )
 
 def _bower_component_impl(ctx):
-  transitive_zipfiles = depset([ctx.file.zipfile])
-  for d in ctx.attr.deps:
-    transitive_zipfiles += d.transitive_zipfiles
+    transitive_zipfiles = depset([ctx.file.zipfile])
+    for d in ctx.attr.deps:
+        transitive_zipfiles += d.transitive_zipfiles
 
-  transitive_licenses = depset()
-  if ctx.file.license:
-    transitive_licenses += depset([ctx.file.license])
+    transitive_licenses = depset()
+    if ctx.file.license:
+        transitive_licenses += depset([ctx.file.license])
 
-  for d in ctx.attr.deps:
-    transitive_licenses += d.transitive_licenses
+    for d in ctx.attr.deps:
+        transitive_licenses += d.transitive_licenses
 
-  transitive_versions = depset(ctx.files.version_json)
-  for d in ctx.attr.deps:
-    transitive_versions += d.transitive_versions
+    transitive_versions = depset(ctx.files.version_json)
+    for d in ctx.attr.deps:
+        transitive_versions += d.transitive_versions
 
-  return struct(
-    transitive_zipfiles=transitive_zipfiles,
-    transitive_versions=transitive_versions,
-    transitive_licenses=transitive_licenses,
-  )
+    return struct(
+        transitive_licenses = transitive_licenses,
+        transitive_versions = transitive_versions,
+        transitive_zipfiles = transitive_zipfiles,
+    )
 
 _common_attrs = {
     "deps": attr.label_list(providers = [
@@ -149,35 +161,37 @@
 }
 
 def _js_component(ctx):
-  dir = ctx.outputs.zip.path + ".dir"
-  name = ctx.outputs.zip.basename
-  if name.endswith(".zip"):
-    name = name[:-4]
-  dest = "%s/%s" % (dir, name)
-  cmd = " && ".join([
-    "TZ=UTC",
-    "export TZ",
-    "mkdir -p %s" % dest,
-    "cp %s %s/" % (' '.join([s.path for s in ctx.files.srcs]), dest),
-    "cd %s" % dir,
-    "find . -exec touch -t 198001010000 '{}' ';'",
-    "zip -Xqr ../%s *" %  ctx.outputs.zip.basename
-  ])
+    dir = ctx.outputs.zip.path + ".dir"
+    name = ctx.outputs.zip.basename
+    if name.endswith(".zip"):
+        name = name[:-4]
+    dest = "%s/%s" % (dir, name)
+    cmd = " && ".join([
+        "TZ=UTC",
+        "export TZ",
+        "mkdir -p %s" % dest,
+        "cp %s %s/" % (" ".join([s.path for s in ctx.files.srcs]), dest),
+        "cd %s" % dir,
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xqr ../%s *" % ctx.outputs.zip.basename,
+    ])
 
-  ctx.actions.run_shell(
-    inputs = ctx.files.srcs,
-    outputs = [ctx.outputs.zip],
-    command = cmd,
-    mnemonic = "GenBowerZip")
+    ctx.actions.run_shell(
+        inputs = ctx.files.srcs,
+        outputs = [ctx.outputs.zip],
+        command = cmd,
+        mnemonic = "GenBowerZip",
+    )
 
-  licenses = depset()
-  if ctx.file.license:
-    licenses += depset([ctx.file.license])
+    licenses = depset()
+    if ctx.file.license:
+        licenses += depset([ctx.file.license])
 
-  return struct(
-    transitive_zipfiles=list([ctx.outputs.zip]),
-    transitive_versions=depset(),
-    transitive_licenses=licenses)
+    return struct(
+        transitive_licenses = licenses,
+        transitive_versions = depset(),
+        transitive_zipfiles = list([ctx.outputs.zip]),
+    )
 
 js_component = rule(
     _js_component,
@@ -203,61 +217,65 @@
 )
 
 # TODO(hanwen): make license mandatory.
-def bower_component(name, license=None, **kwargs):
-  prefix = "//lib:LICENSE-"
-  if license and not license.startswith(prefix):
-    license = prefix + license
-  _bower_component(
-    name=name,
-    license=license,
-    zipfile="@%s//:zipfile"% name,
-    version_json="@%s//:version_json" % name,
-    **kwargs)
+def bower_component(name, license = None, **kwargs):
+    prefix = "//lib:LICENSE-"
+    if license and not license.startswith(prefix):
+        license = prefix + license
+    _bower_component(
+        name = name,
+        license = license,
+        zipfile = "@%s//:zipfile" % name,
+        version_json = "@%s//:version_json" % name,
+        **kwargs
+    )
 
 def _bower_component_bundle_impl(ctx):
-  """A bunch of bower components zipped up."""
-  zips = depset()
-  for d in ctx.attr.deps:
-    zips += d.transitive_zipfiles
+    """A bunch of bower components zipped up."""
+    zips = depset()
+    for d in ctx.attr.deps:
+        zips += d.transitive_zipfiles
 
-  versions = depset()
-  for d in ctx.attr.deps:
-    versions += d.transitive_versions
+    versions = depset()
+    for d in ctx.attr.deps:
+        versions += d.transitive_versions
 
-  licenses = depset()
-  for d in ctx.attr.deps:
-    licenses += d.transitive_versions
+    licenses = depset()
+    for d in ctx.attr.deps:
+        licenses += d.transitive_versions
 
-  out_zip = ctx.outputs.zip
-  out_versions = ctx.outputs.version_json
+    out_zip = ctx.outputs.zip
+    out_versions = ctx.outputs.version_json
 
-  ctx.actions.run_shell(
-    inputs=list(zips),
-    outputs=[out_zip],
-    command=" && ".join([
-      "p=$PWD",
-      "TZ=UTC",
-      "export TZ",
-      "rm -rf %s.dir" % out_zip.path,
-      "mkdir -p %s.dir/bower_components" % out_zip.path,
-      "cd %s.dir/bower_components" % out_zip.path,
-      "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips])),
-      "cd ..",
-      "find . -exec touch -t 198001010000 '{}' ';'",
-      "zip -Xqr $p/%s bower_components/*" % out_zip.path,
-    ]),
-    mnemonic="BowerCombine")
+    ctx.actions.run_shell(
+        inputs = list(zips),
+        outputs = [out_zip],
+        command = " && ".join([
+            "p=$PWD",
+            "TZ=UTC",
+            "export TZ",
+            "rm -rf %s.dir" % out_zip.path,
+            "mkdir -p %s.dir/bower_components" % out_zip.path,
+            "cd %s.dir/bower_components" % out_zip.path,
+            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips])),
+            "cd ..",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
+        ]),
+        mnemonic = "BowerCombine",
+    )
 
-  ctx.actions.run_shell(
-    inputs=list(versions),
-    outputs=[out_versions],
-    mnemonic="BowerVersions",
-    command="(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions]), out_versions.path))
+    ctx.actions.run_shell(
+        inputs = list(versions),
+        outputs = [out_versions],
+        mnemonic = "BowerVersions",
+        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions]), out_versions.path),
+    )
 
-  return struct(
-    transitive_zipfiles=zips,
-    transitive_versions=versions,
-    transitive_licenses=licenses)
+    return struct(
+        transitive_licenses = licenses,
+        transitive_versions = versions,
+        transitive_zipfiles = zips,
+    )
 
 bower_component_bundle = rule(
     _bower_component_bundle_impl,
@@ -268,94 +286,113 @@
     },
 )
 
-"""Groups a set of bower components together in a zip file.
+def _bundle_impl(ctx):
+    """Groups a set of .html and .js together in a zip file.
 
-Outputs:
-  NAME-versions.json:
-    a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
-    transitive dependencies.
-  NAME.zip:
-    a zip file containing the transitive dependencies for this bundle.
-"""
+    Outputs:
+      NAME-versions.json:
+        a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
+        transitive dependencies.
+    NAME.zip:
+      a zip file containing the transitive dependencies for this bundle.
+    """
 
-def _vulcanize_impl(ctx):
-  # intermediate artifact if split is wanted.
-  if ctx.attr.split:
-    vulcanized = ctx.new_file(
-      ctx.configuration.genfiles_dir, ctx.outputs.html, ".vulcanized.html")
-  else:
-    vulcanized = ctx.outputs.html
-  destdir = ctx.outputs.html.path + ".dir"
-  zips =  [z for d in ctx.attr.deps for z in d.transitive_zipfiles ]
+    # intermediate artifact if split is wanted.
+    if ctx.attr.split:
+        bundled = ctx.new_file(
+            ctx.configuration.genfiles_dir,
+            ctx.outputs.html,
+            ".bundled.html",
+        )
+    else:
+        bundled = ctx.outputs.html
+    destdir = ctx.outputs.html.path + ".dir"
+    zips = [z for d in ctx.attr.deps for z in d.transitive_zipfiles]
 
-  hermetic_npm_binary = " ".join([
-    'python',
-    "$p/" + ctx.file._run_npm.path,
-    "$p/" + ctx.file._vulcanize_archive.path,
-    '--inline-scripts',
-    '--inline-css',
-    '--strip-comments',
-    '--out-html', "$p/" + vulcanized.path,
-    ctx.file.app.path
-  ])
+    hermetic_npm_binary = " ".join([
+        "python",
+        "$p/" + ctx.file._run_npm.path,
+        "$p/" + ctx.file._bundler_archive.path,
+        "--inline-scripts",
+        "--inline-css",
+        "--strip-comments",
+        "--out-file",
+        "$p/" + bundled.path,
+        ctx.file.app.path,
+    ])
 
-  pkg_dir = ctx.attr.pkg.lstrip("/")
-  cmd = " && ".join([
-    # unpack dependencies.
-    "export PATH",
-    "p=$PWD",
-    "rm -rf %s" % destdir,
-    "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
-    "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
-      ' '.join([z.path for z in zips]), destdir, pkg_dir),
-    "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
-    "cd %s" % destdir,
-    hermetic_npm_binary,
-  ])
+    pkg_dir = ctx.attr.pkg.lstrip("/")
+    cmd = " && ".join([
+        # unpack dependencies.
+        "export PATH",
+        "p=$PWD",
+        "rm -rf %s" % destdir,
+        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
+        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
+            " ".join([z.path for z in zips]),
+            destdir,
+            pkg_dir,
+        ),
+        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
+        "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.actions.run_shell(
-    mnemonic = "Vulcanize",
-    inputs = [ctx.file._run_npm, ctx.file.app,
-              ctx.file._vulcanize_archive
-    ] + list(zips) + ctx.files.srcs,
-    outputs = [vulcanized],
-    command = cmd,
-    **node_tweaks)
-
-  if ctx.attr.split:
-    hermetic_npm_command = "export PATH && " + " ".join([
-      'python',
-      ctx.file._run_npm.path,
-      ctx.file._crisper_archive.path,
-      "--always-write-script",
-      "--source", vulcanized.path,
-      "--html", ctx.outputs.html.path,
-      "--js", ctx.outputs.js.path])
-
+    # 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(
+        execution_requirements = {"local": "1"},
+        use_default_shell_env = True,
+    )
     ctx.actions.run_shell(
-      mnemonic = "Crisper",
-      inputs = [ctx.file._run_npm, ctx.file.app,
-                ctx.file._crisper_archive, vulcanized],
-      outputs = [ctx.outputs.js, ctx.outputs.html],
-      command = hermetic_npm_command,
-      **node_tweaks)
+        mnemonic = "Bundle",
+        inputs = [
+            ctx.file._run_npm,
+            ctx.file.app,
+            ctx.file._bundler_archive,
+        ] + list(zips) + ctx.files.srcs,
+        outputs = [bundled],
+        command = cmd,
+        **node_tweaks
+    )
 
-def _vulcanize_output_func(name, split):
-  _ignore = [name]  # unused.
-  out = {"html": "%{name}.html"}
-  if split:
-    out["js"] = "%{name}.js"
-  return out
+    if ctx.attr.split:
+        hermetic_npm_command = "export PATH && " + " ".join([
+            "python",
+            ctx.file._run_npm.path,
+            ctx.file._crisper_archive.path,
+            "--always-write-script",
+            "--source",
+            bundled.path,
+            "--html",
+            ctx.outputs.html.path,
+            "--js",
+            ctx.outputs.js.path,
+        ])
 
-_vulcanize_rule = rule(
-    _vulcanize_impl,
+        ctx.actions.run_shell(
+            mnemonic = "Crisper",
+            inputs = [
+                ctx.file._run_npm,
+                ctx.file.app,
+                ctx.file._crisper_archive,
+                bundled,
+            ],
+            outputs = [ctx.outputs.js, ctx.outputs.html],
+            command = hermetic_npm_command,
+            **node_tweaks
+        )
+
+def _bundle_output_func(name, split):
+    _ignore = [name]  # unused.
+    out = {"html": "%{name}.html"}
+    if split:
+        out["js"] = "%{name}.js"
+    return out
+
+_bundle_rule = rule(
+    _bundle_impl,
     attrs = {
         "deps": attr.label_list(providers = ["transitive_zipfiles"]),
         "app": attr.label(
@@ -375,8 +412,8 @@
             default = Label("//tools/js:run_npm_binary.py"),
             allow_single_file = True,
         ),
-        "_vulcanize_archive": attr.label(
-            default = Label("@vulcanize//:%s" % _npm_tarball("vulcanize")),
+        "_bundler_archive": attr.label(
+            default = Label("@polymer-bundler//:%s" % _npm_tarball("polymer-bundler")),
             allow_single_file = True,
         ),
         "_crisper_archive": attr.label(
@@ -384,13 +421,100 @@
             allow_single_file = True,
         ),
     },
-    outputs = _vulcanize_output_func,
+    outputs = _bundle_output_func,
 )
 
-def vulcanize(*args, **kwargs):
-  """Vulcanize runs vulcanize and (optionally) crisper on a set of sources."""
-  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
+def bundle_assets(*args, **kwargs):
+    """Combine html, js, css files and optionally split into js and html bundles."""
+    _bundle_rule(*args, pkg = native.package_name(), **kwargs)
 
-def polygerrit_plugin(*args, **kwargs):
-  """Bundles plugin dependencies for deployment."""
-  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
+def polygerrit_plugin(name, app, srcs = [], assets = None, **kwargs):
+    """Bundles plugin dependencies for deployment.
+
+    This rule bundles all Polymer elements and JS dependencies into .html and .js files.
+    Run-time dependencies (e.g. JS libraries loaded after plugin starts) should be provided using "assets" property.
+    Output of this rule is a FileSet with "${name}_fs", with deploy artifacts in "plugins/${name}/static".
+
+    Args:
+      name: String, plugin name.
+      app: String, the main or root source file.
+      assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
+      srcs: Source files required for combining.
+    """
+
+    # Combines all .js and .html files into foo_combined.js and foo_combined.html
+    _bundle_rule(
+        name = name + "_combined",
+        app = app,
+        srcs = srcs if app in srcs else srcs + [app],
+        pkg = native.package_name(),
+        **kwargs
+    )
+
+    closure_js_binary(
+        name = name + "_bin",
+        compilation_level = "SIMPLE",
+        defs = [
+            "--polymer_version=1",
+            "--language_out=ECMASCRIPT6",
+            "--rewrite_polyfills=false",
+        ],
+        deps = [
+            name + "_closure_lib",
+        ],
+    )
+
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = [name + "_combined.js"],
+        convention = "GOOGLE",
+        no_closure_library = True,
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "//polygerrit-ui/app/externs:plugin",
+        ],
+    )
+
+    native.genrule(
+        name = name + "_rename_html",
+        srcs = [name + "_combined.html"],
+        outs = [name + ".html"],
+        cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + name + ".js\"/g' $(SRCS) > $(OUTS)",
+        output_to_bindir = True,
+    )
+
+    native.genrule(
+        name = name + "_rename_js",
+        srcs = [name + "_bin.js"],
+        outs = [name + ".js"],
+        cmd = "cp $< $@",
+        output_to_bindir = True,
+    )
+
+    static_files = [
+        name + ".js",
+        name + ".html",
+    ]
+
+    if assets:
+        nested, direct = [], []
+        for x in assets:
+            target = nested if "/" in x else direct
+            target.append(x)
+
+        static_files += direct
+
+        if nested:
+            native.genrule(
+                name = name + "_copy_assets",
+                srcs = assets,
+                outs = [f.split("/")[-1] for f in nested],
+                cmd = "cp $(SRCS) $(@D)",
+                output_to_bindir = True,
+            )
+            static_files += [":" + name + "_copy_assets"]
+
+    native.filegroup(
+        name = name,
+        srcs = static_files,
+    )
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 19974a7..d711356 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -43,15 +43,17 @@
         if findex != -1:
             break
     if findex == -1:
-        fail("%s does not contain any of %s",
-                         fname, _PREFIXES)
+        fail("%s does not contain any of %s" % (fname, _PREFIXES))
     return ".".join(toks[findex:]) + ".class"
 
 def _impl(ctx):
     classes = ",".join(
-        [_AsClassName(x) for x in ctx.attr.srcs])
-    ctx.file_action(output=ctx.outputs.out, content=_OUTPUT % (
-            classes, ctx.attr.outname))
+        [_AsClassName(x) for x in ctx.attr.srcs],
+    )
+    ctx.file_action(output = ctx.outputs.out, content = _OUTPUT % (
+        classes,
+        ctx.attr.outname,
+    ))
 
 _GenSuite = rule(
     attrs = {
@@ -64,10 +66,25 @@
 
 def junit_tests(name, srcs, **kwargs):
     s_name = name + "TestSuite"
-    _GenSuite(name = s_name,
-              srcs = srcs,
-              outname = s_name)
-    native.java_test(name = name,
-                     test_class = s_name,
-                     srcs = srcs + [":"+s_name],
-                     **kwargs)
+    _GenSuite(
+        name = s_name,
+        srcs = srcs,
+        outname = s_name,
+    )
+    jvm_flags = kwargs.get("jvm_flags", [])
+    jvm_flags = jvm_flags + select({
+        "//:java9": [
+            # Enforce JDK 8 compatibility on Java 9, see
+            # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
+            "-Djava.locale.providers=COMPAT,CLDR,SPI",
+            "--add-modules java.activation",
+            "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED",
+        ],
+        "//conditions:default": [],
+    })
+    native.java_test(
+        name = name,
+        test_class = s_name,
+        srcs = srcs + [":" + s_name],
+        **dict(kwargs, jvm_flags = jvm_flags)
+    )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 476ccb9..ebe57f2 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -35,7 +35,7 @@
             continue
 
         handled_rules.append(rule_name)
-        for c in child.getchildren():
+        for c in list(child):
             if c.tag != "rule-input":
                 continue
 
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index 38dfbe5..d059216 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -1,57 +1,57 @@
 def normalize_target_name(target):
-  return target.replace("//", "").replace("/", "__").replace(":", "___")
+    return target.replace("//", "").replace("/", "__").replace(":", "___")
 
 def license_map(name, targets = [], opts = [], **kwargs):
-  """Generate XML for all targets that depend directly on a LICENSE file"""
-  xmls = []
-  tools = [ "//tools/bzl:license-map.py", "//lib:all-licenses" ]
-  for target in targets:
-    subname = name + "_" + normalize_target_name(target) + ".xml"
-    xmls.append("$(location :%s)" % subname)
-    tools.append(subname)
-    native.genquery(
-      name = subname,
-      scope = [ target ],
+    """Generate XML for all targets that depend directly on a LICENSE file"""
+    xmls = []
+    tools = ["//tools/bzl:license-map.py", "//lib:all-licenses"]
+    for target in targets:
+        subname = name + "_" + normalize_target_name(target) + ".xml"
+        xmls.append("$(location :%s)" % subname)
+        tools.append(subname)
+        native.genquery(
+            name = subname,
+            scope = [target],
 
-      # Find everything that depends on a license file, but remove
-      # the license files themselves from this list.
-      expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
+            # Find everything that depends on a license file, but remove
+            # the license files themselves from this list.
+            expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
 
-      # We are interested in the edges of the graph ({java_library,
-      # license-file} tuples).  'query' provides this in the XML output.
-      opts = [ "--output=xml", ],
+            # We are interested in the edges of the graph ({java_library,
+            # license-file} tuples).  'query' provides this in the XML output.
+            opts = ["--output=xml"],
+        )
+
+    # post process the XML into our favorite format.
+    native.genrule(
+        name = "gen_license_txt_" + name,
+        cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+        outs = [name + ".txt"],
+        tools = tools,
+        **kwargs
     )
 
-  # post process the XML into our favorite format.
-  native.genrule(
-    name = "gen_license_txt_" + name,
-    cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
-    outs = [ name + ".txt" ],
-    tools = tools,
-    **kwargs
-  )
-
 def license_test(name, target):
-  """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
-  txt = name + "-forbidden.txt"
+    """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
+    txt = name + "-forbidden.txt"
 
-  # fully qualify target name.
-  if target[0] not in ":/":
-    target = ":" + target
-  if target[0] != "/":
-    target = "//" + PACKAGE_NAME + target
+    # fully qualify target name.
+    if target[0] not in ":/":
+        target = ":" + target
+    if target[0] != "/":
+        target = "//" + native.package_name() + target
 
-  forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
-  native.genquery(
-    name = txt,
-    scope = [ target, forbidden ],
-    # Find everything that depends on a license file, but remove
-    # the license files themselves from this list.
-    expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
-  )
-  native.sh_test(
-    name = name,
-    srcs = [ "//tools/bzl:test_license.sh" ],
-    args  = [ "$(location :%s)" % txt ],
-    data = [ txt ],
-  )
+    forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+    native.genquery(
+        name = txt,
+        scope = [target, forbidden],
+        # Find everything that depends on a license file, but remove
+        # the license files themselves from this list.
+        expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
+    )
+    native.sh_test(
+        name = name,
+        srcs = ["//tools/bzl:test_license.sh"],
+        args = ["$(location :%s)" % txt],
+        data = [txt],
+    )
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
index c255c0c..71aa91c 100644
--- a/tools/bzl/maven.bzl
+++ b/tools/bzl/maven.bzl
@@ -15,18 +15,18 @@
 # Merge maven files
 
 def cmd(jars):
-  return ('$(location //tools:merge_jars) $@ '
-          + ' '.join(['$(location %s)' % j for j in jars]))
+    return ("$(location //tools:merge_jars) $@ " +
+            " ".join(["$(location %s)" % j for j in jars]))
 
 def merge_maven_jars(name, srcs, **kwargs):
-  native.genrule(
-    name = '%s__merged_bin' % name,
-    cmd = cmd(srcs),
-    tools = srcs + ['//tools:merge_jars'],
-    outs = ['%s__merged.jar' % name],
-  )
-  native.java_import(
-    name = name,
-    jars = [':%s__merged_bin' % name],
-    **kwargs
-  )
+    native.genrule(
+        name = "%s__merged_bin" % name,
+        cmd = cmd(srcs),
+        tools = srcs + ["//tools:merge_jars"],
+        outs = ["%s__merged.jar" % name],
+    )
+    native.java_import(
+        name = name,
+        jars = [":%s__merged_bin" % name],
+        **kwargs
+    )
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 55bfae1..2ebb2c2 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -7,69 +7,70 @@
 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
+    """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])
+    jar = artifact.lower() + "-" + file_version
+    url = "/".join([
+        ctx.attr.repository,
+        group.replace(".", "/"),
+        artifact,
+        version,
+        artifact + "-" + file_version,
+    ])
 
-  return jar, url
+    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
+    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)
+    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,
-  )
+    return struct(
+        fully_qualified_name = fully_qualified_name,
+        group_id = group_id,
+        artifact_id = artifact_id,
+        packaging = packaging,
+        classifier = classifier,
+        version = version,
+    )
 
 def _format_deps(attr, deps):
-  formatted_deps = ""
-  if deps:
-    if len(deps) == 1:
-      formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
-    else:
-      formatted_deps += "%s = [\n" % attr
-      for dep in deps:
-        formatted_deps += "        \'%s\',\n" % dep
-      formatted_deps += "    ],"
-  return formatted_deps
+    formatted_deps = ""
+    if deps:
+        if len(deps) == 1:
+            formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
+        else:
+            formatted_deps += "%s = [\n" % attr
+            for dep in deps:
+                formatted_deps += "        \'%s\',\n" % dep
+            formatted_deps += "    ],"
+    return formatted_deps
 
 def _generate_build_files(ctx, binjar, srcjar):
-  header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
-  srcjar_attr = ""
-  if srcjar:
-    srcjar_attr = 'srcjar = "%s",' % srcjar
-  contents = """
+    header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
+    srcjar_attr = ""
+    if srcjar:
+        srcjar_attr = 'srcjar = "%s",' % srcjar
+    contents = """
 {header}
 package(default_visibility = ['//visibility:public'])
 java_import(
@@ -86,22 +87,24 @@
     {deps}
     {exports}
 )
-\n""".format(srcjar_attr = srcjar_attr,
-             header = header,
-             binjar = binjar,
-             deps = _format_deps("deps", ctx.attr.deps),
-             exports = _format_deps("exports", ctx.attr.exports))
-  if srcjar:
-    contents += """
+\n""".format(
+        srcjar_attr = srcjar_attr,
+        header = header,
+        binjar = binjar,
+        deps = _format_deps("deps", ctx.attr.deps),
+        exports = _format_deps("exports", ctx.attr.exports),
+    )
+    if srcjar:
+        contents += """
 java_import(
     name = 'src',
     jars = ['{srcjar}'],
 )
 """.format(srcjar = srcjar)
-  ctx.file('%s/BUILD' % ctx.path("jar"), contents, False)
+    ctx.file("%s/BUILD" % ctx.path("jar"), contents, False)
 
-  # Compatibility layer for java_import_external from rules_closure
-  contents = """
+    # Compatibility layer for java_import_external from rules_closure
+    contents = """
 {header}
 package(default_visibility = ['//visibility:public'])
 
@@ -110,52 +113,53 @@
     actual = "@{rule_name}//jar",
 )
 \n""".format(rule_name = ctx.name, header = header)
-  ctx.file("BUILD", contents, False)
+    ctx.file("BUILD", contents, False)
 
 def _maven_jar_impl(ctx):
-  """rule to download a Maven archive."""
-  coordinates = _create_coordinates(ctx.attr.artifact)
+    """rule to download a Maven archive."""
+    coordinates = _create_coordinates(ctx.attr.artifact)
 
-  name = ctx.name
-  sha1 = ctx.attr.sha1
+    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)
+    parts = ctx.attr.artifact.split(":")
 
-  binjar = jar + '.jar'
-  binjar_path = ctx.path('/'.join(['jar', binjar]))
-  binurl = url + '.jar'
+    # TODO(davido): Only releases for now, implement handling snapshots
+    jar, url = _maven_release(ctx, parts)
 
-  python = ctx.which("python")
-  script = ctx.path(ctx.attr._download_script)
+    binjar = jar + ".jar"
+    binjar_path = ctx.path("/".join(["jar", binjar]))
+    binurl = url + ".jar"
 
-  args = [python, script, "-o", binjar_path, "-u", binurl]
-  if ctx.attr.sha1:
-    args.extend(["-v", sha1])
-  if ctx.attr.unsign:
-    args.append('--unsign')
-  for x in ctx.attr.exclude:
-    args.extend(['-x', x])
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
 
-  out = ctx.execute(args)
+    args = [python, script, "-o", binjar_path, "-u", binurl]
+    if ctx.attr.sha1:
+        args.extend(["-v", sha1])
+    if ctx.attr.unsign:
+        args.append("--unsign")
+    for x in ctx.attr.exclude:
+        args.extend(["-x", x])
 
-  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_files(ctx, binjar, srcjar)
+    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_files(ctx, binjar, srcjar)
 
 maven_jar = repository_rule(
     attrs = {
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index d6a4c78..40dd769 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,7 @@
 
 # War packaging.
 
-jar_filetype = FileType([".jar"])
+jar_filetype = [".jar"]
 
 LIBS = [
     "//java/com/google/gerrit/common:version",
@@ -23,7 +23,7 @@
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
-    "//lib/log:impl_log4j",
+    "//lib/log:impl-log4j",
     "//resources:log4j-config",
 ]
 
@@ -32,93 +32,93 @@
 ]
 
 def _add_context(in_file, output):
-  input_path = in_file.path
-  return [
-    'unzip -qd %s %s' % (output, input_path)
-  ]
+    input_path = in_file.path
+    return [
+        "unzip -qd %s %s" % (output, input_path),
+    ]
 
 def _add_file(in_file, output):
-  output_path = output
-  input_path = in_file.path
-  short_path = in_file.short_path
-  n = in_file.basename
+    output_path = output
+    input_path = in_file.path
+    short_path = in_file.short_path
+    n = in_file.basename
 
-  if short_path.startswith('gerrit-'):
-    n = short_path.split('/')[0] + '-' + n
-  elif short_path.startswith('java/'):
-    n = short_path[5:].replace('/', '_')
-  output_path += n
-  return [
-    'test -L %s || ln -s $(pwd)/%s %s' % (output_path, input_path, output_path)
-  ]
+    if short_path.startswith("gerrit-"):
+        n = short_path.split("/")[0] + "-" + n
+    elif short_path.startswith("java/"):
+        n = short_path[5:].replace("/", "_")
+    output_path += n
+    return [
+        "test -L %s || ln -s $(pwd)/%s %s" % (output_path, input_path, output_path),
+    ]
 
 def _make_war(input_dir, output):
-  return '(%s)' % ' && '.join([
-    'root=$(pwd)',
-    'TZ=UTC',
-    'export TZ',
-    'cd %s' % input_dir,
-    "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
-    'zip -X -9qr ${root}/%s .' % (output.path),
-  ])
+    return "(%s)" % " && ".join([
+        "root=$(pwd)",
+        "TZ=UTC",
+        "export TZ",
+        "cd %s" % input_dir,
+        "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
+        "zip -X -9qr ${root}/%s ." % (output.path),
+    ])
 
 def _war_impl(ctx):
-  war = ctx.outputs.war
-  build_output = war.path + '.build_output'
-  inputs = []
+    war = ctx.outputs.war
+    build_output = war.path + ".build_output"
+    inputs = []
 
-  # Create war layout
-  cmd = [
-    'set -e;rm -rf ' + build_output,
-    'mkdir -p ' + build_output,
-    'mkdir -p %s/WEB-INF/lib' % build_output,
-    'mkdir -p %s/WEB-INF/pgm-lib' % build_output,
-  ]
+    # Create war layout
+    cmd = [
+        "set -e;rm -rf " + build_output,
+        "mkdir -p " + build_output,
+        "mkdir -p %s/WEB-INF/lib" % build_output,
+        "mkdir -p %s/WEB-INF/pgm-lib" % build_output,
+    ]
 
-  # Add lib
-  transitive_lib_deps = depset()
-  for l in ctx.attr.libs:
-    if hasattr(l, 'java'):
-      transitive_lib_deps += l.java.transitive_runtime_deps
-    elif hasattr(l, 'files'):
-      transitive_lib_deps += l.files
+    # Add lib
+    transitive_lib_deps = depset()
+    for l in ctx.attr.libs:
+        if hasattr(l, "java"):
+            transitive_lib_deps += l.java.transitive_runtime_deps
+        elif hasattr(l, "files"):
+            transitive_lib_deps += l.files
 
-  for dep in transitive_lib_deps:
-    cmd += _add_file(dep, build_output + '/WEB-INF/lib/')
-    inputs.append(dep)
+    for dep in transitive_lib_deps:
+        cmd += _add_file(dep, build_output + "/WEB-INF/lib/")
+        inputs.append(dep)
 
-  # Add pgm lib
-  transitive_pgmlib_deps = depset()
-  for l in ctx.attr.pgmlibs:
-    transitive_pgmlib_deps += l.java.transitive_runtime_deps
+    # Add pgm lib
+    transitive_pgmlib_deps = depset()
+    for l in ctx.attr.pgmlibs:
+        transitive_pgmlib_deps += l.java.transitive_runtime_deps
 
-  for dep in transitive_pgmlib_deps:
-    if dep not in inputs:
-      cmd += _add_file(dep, build_output + '/WEB-INF/pgm-lib/')
-      inputs.append(dep)
+    for dep in transitive_pgmlib_deps:
+        if dep not in inputs:
+            cmd += _add_file(dep, build_output + "/WEB-INF/pgm-lib/")
+            inputs.append(dep)
 
-  # Add context
-  transitive_context_deps = depset()
-  if ctx.attr.context:
-    for jar in ctx.attr.context:
-      if hasattr(jar, 'java'):
-        transitive_context_deps += jar.java.transitive_runtime_deps
-      elif hasattr(jar, 'files'):
-        transitive_context_deps += jar.files
-  for dep in transitive_context_deps:
-    cmd += _add_context(dep, build_output)
-    inputs.append(dep)
+    # Add context
+    transitive_context_deps = depset()
+    if ctx.attr.context:
+        for jar in ctx.attr.context:
+            if hasattr(jar, "java"):
+                transitive_context_deps += jar.java.transitive_runtime_deps
+            elif hasattr(jar, "files"):
+                transitive_context_deps += jar.files
+    for dep in transitive_context_deps:
+        cmd += _add_context(dep, build_output)
+        inputs.append(dep)
 
-  # Add zip war
-  cmd.append(_make_war(build_output, war))
+    # Add zip war
+    cmd.append(_make_war(build_output, war))
 
-  ctx.actions.run_shell(
-    inputs = inputs,
-    outputs = [war],
-    mnemonic = 'WAR',
-    command = '\n'.join(cmd),
-    use_default_shell_env = True,
-  )
+    ctx.actions.run_shell(
+        inputs = inputs,
+        outputs = [war],
+        mnemonic = "WAR",
+        command = "\n".join(cmd),
+        use_default_shell_env = True,
+    )
 
 # context: go to the root directory
 # libs: go to the WEB-INF/lib directory
@@ -133,25 +133,25 @@
     implementation = _war_impl,
 )
 
-def pkg_war(name, ui = 'ui_optdbg', context = [], doc = False, **kwargs):
-  doc_ctx = []
-  doc_lib = []
-  ui_deps = []
-  if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
-    ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
-  if ui and ui != 'polygerrit':
-    ui_deps.append('//gerrit-gwtui:%s' % ui)
-  if doc:
-    doc_ctx.append('//Documentation:html')
-    doc_lib.append('//Documentation:index')
+def pkg_war(name, ui = "ui_optdbg", context = [], doc = False, **kwargs):
+    doc_ctx = []
+    doc_lib = []
+    ui_deps = []
+    if ui == "polygerrit" or ui == "ui_optdbg" or ui == "ui_optdbg_r":
+        ui_deps.append("//polygerrit-ui/app:polygerrit_ui")
+    if ui and ui != "polygerrit":
+        ui_deps.append("//gerrit-gwtui:%s" % ui)
+    if doc:
+        doc_ctx.append("//Documentation:html")
+        doc_lib.append("//Documentation:index")
 
-  _pkg_war(
-    name = name,
-    libs = LIBS + doc_lib,
-    pgmlibs = PGMLIBS,
-    context = doc_ctx + context + ui_deps + [
-      '//java:gerrit-main-class_deploy.jar',
-      '//webapp:assets',
-    ],
-    **kwargs
-  )
+    _pkg_war(
+        name = name,
+        libs = LIBS + doc_lib,
+        pgmlibs = PGMLIBS,
+        context = doc_ctx + context + ui_deps + [
+            "//java:gerrit-main-class_deploy.jar",
+            "//webapp:assets",
+        ],
+        **kwargs
+    )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 23f88df..5ae7dd9 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,11 +1,11 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load(
     "//tools/bzl:gwt.bzl",
+    "GWT_COMPILER_ARGS",
+    "GWT_JVM_ARGS",
     "GWT_PLUGIN_DEPS",
     "GWT_PLUGIN_DEPS_NEVERLINK",
     "GWT_TRANSITIVE_DEPS",
-    "GWT_COMPILER_ARGS",
-    "GWT_JVM_ARGS",
     "gwt_binary",
 )
 
@@ -21,82 +21,84 @@
 ]
 
 def gerrit_plugin(
-    name,
-    deps = [],
-    provided_deps = [],
-    srcs = [],
-    gwt_module = [],
-    resources = [],
-    manifest_entries = [],
-    dir_name = None,
-    target_suffix = "",
-    **kwargs):
-  native.java_library(
-    name = name + '__plugin',
-    srcs = srcs,
-    resources = resources,
-    deps = provided_deps + deps + GWT_PLUGIN_DEPS_NEVERLINK + PLUGIN_DEPS_NEVERLINK,
-    visibility = ['//visibility:public'],
-    **kwargs
-  )
-
-  static_jars = []
-  if gwt_module:
-    static_jars = [':%s-static' % name]
-
-  if not dir_name:
-    dir_name = name
-
-  native.java_binary(
-    name = '%s__non_stamped' % name,
-    deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
-    main_class = 'Dummy',
-    runtime_deps = [
-      ':%s__plugin' % name,
-    ] + static_jars,
-    visibility = ['//visibility:public'],
-    **kwargs
-  )
-
-  if gwt_module:
+        name,
+        deps = [],
+        provided_deps = [],
+        srcs = [],
+        gwt_module = [],
+        resources = [],
+        manifest_entries = [],
+        dir_name = None,
+        target_suffix = "",
+        **kwargs):
     native.java_library(
-      name = name + '__gwt_module',
-      resources = depset(srcs + resources).to_list(),
-      runtime_deps = deps + GWT_PLUGIN_DEPS,
-      visibility = ['//visibility:public'],
-      **kwargs
-    )
-    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,
+        name = name + "__plugin",
+        srcs = srcs,
+        resources = resources,
+        deps = provided_deps + deps + GWT_PLUGIN_DEPS_NEVERLINK + PLUGIN_DEPS_NEVERLINK,
+        visibility = ["//visibility:public"],
+        **kwargs
     )
 
-  # TODO(davido): Remove manual merge of manifest file when this feature
-  # request is implemented: https://github.com/bazelbuild/bazel/issues/2009
-  genrule2(
-    name = name + target_suffix,
-    stamp = 1,
-    srcs = ['%s__non_stamped_deploy.jar' % name],
-    cmd = " && ".join([
-      "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_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%s.jar' % (name, target_suffix)],
-    visibility = ['//visibility:public'],
-  )
+    static_jars = []
+    if gwt_module:
+        static_jars = [":%s-static" % name]
+
+    if not dir_name:
+        dir_name = name
+
+    native.java_binary(
+        name = "%s__non_stamped" % name,
+        deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
+        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 = depset(srcs + resources).to_list(),
+            runtime_deps = deps + GWT_PLUGIN_DEPS,
+            visibility = ["//visibility:public"],
+            **kwargs
+        )
+        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 + target_suffix,
+        stamp = 1,
+        srcs = ["%s__non_stamped_deploy.jar" % name],
+        cmd = " && ".join([
+            "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_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%s.jar" % (name, target_suffix)],
+        visibility = ["//visibility:public"],
+    )
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 22c1a80..0c9d023 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -31,9 +31,8 @@
     "//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',
+    "//lib/prolog:compiler-lib",
+    "//proto:reviewdb_java_proto",
 ]
 
 java_library(
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index b99c04e..64d837a 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -52,10 +52,12 @@
                 action='store', default='gerrit', dest='project_name')
 opts.add_option('-b', '--batch', action='store_true',
                 dest='batch', help='Bazel batch option')
+opts.add_option('-j', '--java', action='store',
+                dest='java', help='Post Java 8 support (9|10|11|...)')
 args, _ = opts.parse_args()
 
 batch_option = '--batch' if args.batch else None
-
+custom_java = args.java
 
 def _build_bazel_cmd(*args):
     cmd = ['bazel']
@@ -63,6 +65,9 @@
         cmd.append('--batch')
     for arg in args:
         cmd.append(arg)
+    if custom_java:
+        cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
+        cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
     return cmd
 
 
@@ -70,9 +75,10 @@
     return check_output(_build_bazel_cmd('info', 'output_base')).strip()
 
 
-def gen_bazel_path():
+def gen_bazel_path(ext_location):
     bazel = check_output(['which', 'bazel']).strip().decode('UTF-8')
     with open(path.join(ROOT, ".bazel_path"), 'w') as fd:
+        fd.write("output_base=%s\n" % ext_location)
         fd.write("bazel=%s\n" % bazel)
         fd.write("PATH=%s\n" % environ["PATH"])
 
@@ -301,7 +307,7 @@
     gen_project(args.project_name)
     gen_classpath(ext_location)
     gen_factorypath(ext_location)
-    gen_bazel_path()
+    gen_bazel_path(ext_location)
 
     # TODO(davido): Remove this when GWT gone
     gwt_working_dir = ".gwt_work_dir"
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index d817701..f14262a 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -15,8 +15,8 @@
 
 """This downloads an NPM binary, and bundles it with its dependencies.
 
-This is used to assemble a pinned version of crisper, hosted on the
-Google storage bucket ("repository=GERRIT" in WORKSPACE).
+For full instructions on adding new binaries to the build, see
+http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
 """
 
 from __future__ import print_function
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
index dfcdaca..bdee5ab 100644
--- a/tools/js/run_npm_binary.py
+++ b/tools/js/run_npm_binary.py
@@ -56,7 +56,8 @@
                 extract_one(mem)
         # Extract bin last so other processes only short circuit when
         # extraction is finished.
-        extract_one(tar.getmember(bin))
+        if bin in tar.getnames():
+            extract_one(tar.getmember(bin))
 
 
 def main(args):
@@ -77,7 +78,9 @@
     sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
     outdir = '%s-%s' % (path[:-len(suffix)], sha1)
     rel_bin = os.path.join('package', 'bin', name)
+    rel_lib_bin = os.path.join('package', 'lib', 'bin', name + '.js')
     bin = os.path.join(outdir, rel_bin)
+    libbin = os.path.join(outdir, rel_lib_bin)
     if not os.path.isfile(bin):
         extract(path, outdir, rel_bin)
 
@@ -85,7 +88,12 @@
     if nodejs:
         # Debian installs Node.js as 'nodejs', due to a conflict with another
         # package.
-        subprocess.check_call([nodejs, bin] + args[1:])
+        if not os.path.isfile(bin) and os.path.isfile(libbin):
+            subprocess.check_call([nodejs, libbin] + args[1:])
+        else:
+            subprocess.check_call([nodejs, bin] + args[1:])
+    elif not os.path.isfile(bin) and os.path.isfile(libbin):
+        subprocess.check_call([libbin] + args[1:])
     else:
         subprocess.check_call([bin] + args[1:])
 
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index 3c32bb2..5b497f8 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -25,73 +25,86 @@
 ]))
 
 def maven_package(
-    version,
-    repository = None,
-    url = None,
-    jar = {},
-    src = {},
-    doc = {},
-    war = {}):
+        version,
+        repository = None,
+        url = None,
+        jar = {},
+        src = {},
+        doc = {},
+        war = {}):
+    build_cmd = ["bazel", "build"]
+    mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
+    api_cmd = mvn_cmd[:]
+    api_targets = []
+    for type, d in [("jar", jar), ("java-source", src), ("javadoc", doc)]:
+        for a, t in sorted(d.items()):
+            api_cmd.append("-s %s:%s:$(location %s)" % (a, type, t))
+            api_targets.append(t)
 
-  build_cmd = ['bazel', 'build']
-  mvn_cmd = ['python', 'tools/maven/mvn.py', '-v', version]
-  api_cmd = mvn_cmd[:]
-  api_targets = []
-  for type,d in [('jar', jar), ('java-source', src), ('javadoc', doc)]:
-    for a,t in sorted(d.items()):
-      api_cmd.append('-s %s:%s:$(location %s)' % (a,type,t))
-      api_targets.append(t)
-
-  native.genrule(
-    name = 'gen_api_install',
-    cmd = sh_bang_template % (
-      ' '.join(build_cmd + api_targets),
-      ' '.join(api_cmd + ['-a', 'install'])),
-    srcs = api_targets,
-    outs = ['api_install.sh'],
-    executable = True,
-    testonly = 1,
-  )
-
-  if repository and url:
     native.genrule(
-      name = 'gen_api_deploy',
-      cmd = sh_bang_template % (
-        ' '.join(build_cmd + api_targets),
-        ' '.join(api_cmd + ['-a', 'deploy',
-                            '--repository', repository,
-                            '--url', url])),
-      srcs = api_targets,
-      outs = ['api_deploy.sh'],
-      executable = True,
-      testonly = 1,
+        name = "gen_api_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + api_targets),
+            " ".join(api_cmd + ["-a", "install"]),
+        ),
+        srcs = api_targets,
+        outs = ["api_install.sh"],
+        executable = True,
+        testonly = 1,
     )
 
-  war_cmd = mvn_cmd[:]
-  war_targets = []
-  for a,t in sorted(war.items()):
-    war_cmd.append('-s %s:war:$(location %s)' % (a,t))
-    war_targets.append(t)
+    if repository and url:
+        native.genrule(
+            name = "gen_api_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + api_targets),
+                " ".join(api_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = api_targets,
+            outs = ["api_deploy.sh"],
+            executable = True,
+            testonly = 1,
+        )
 
-  native.genrule(
-    name = 'gen_war_install',
-    cmd = sh_bang_template % (' '.join(build_cmd + war_targets),
-                              ' '.join(war_cmd + ['-a', 'install'])),
-    srcs = war_targets,
-    outs = ['war_install.sh'],
-    executable = True,
-  )
+    war_cmd = mvn_cmd[:]
+    war_targets = []
+    for a, t in sorted(war.items()):
+        war_cmd.append("-s %s:war:$(location %s)" % (a, t))
+        war_targets.append(t)
 
-  if repository and url:
     native.genrule(
-      name = 'gen_war_deploy',
-      cmd = sh_bang_template % (
-          ' '.join(build_cmd + war_targets),
-          ' '.join(war_cmd + [
-        '-a', 'deploy',
-        '--repository', repository,
-        '--url', url])),
-      srcs = war_targets,
-      outs = ['war_deploy.sh'],
-      executable = True,
+        name = "gen_war_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + war_targets),
+            " ".join(war_cmd + ["-a", "install"]),
+        ),
+        srcs = war_targets,
+        outs = ["war_install.sh"],
+        executable = True,
     )
+
+    if repository and url:
+        native.genrule(
+            name = "gen_war_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + war_targets),
+                " ".join(war_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = war_targets,
+            outs = ["war_deploy.sh"],
+            executable = True,
+        )
diff --git a/tools/release-announcement-template.txt b/tools/release-announcement-template.txt
deleted file mode 100644
index 2702f57..0000000
--- a/tools/release-announcement-template.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-Gerrit version {{ data.version }} is now available.{% if data.summary %} {{ data.summary }} {% endif %}Please see the release notes for details.
-
-Release Notes:
-https://www.gerritcodereview.com/releases/{{ data.version.major }}.md{% if data.version.patch %}#{{ data.version.patch }}{% endif %}
-
-Documentation:
-http://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.version }}/index.html
-{% if data.previous %}
-Log of changes since {{ data.previous }}:
-https://gerrit.googlesource.com/gerrit/+log/v{{ data.previous }}..v{{ data.version }}?no-merges
-{% endif %}
-Download:
-https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.version }}.war
-
-SHA1:
-{{ data.sha1 }}
-
-SHA256:
-{{ data.sha256 }}
-
-MD5:
-{{ data.md5 }}
-
-Maintainers' public keys:
-https://www.gerritcodereview.com/releases/public-keys.md
-
diff --git a/tools/release-announcement.py b/tools/release-announcement.py
deleted file mode 100755
index a25a340..0000000
--- a/tools/release-announcement.py
+++ /dev/null
@@ -1,156 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2017 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.
-
-# Generates the text to paste into the email for announcing a new
-# release of Gerrit. The text is generated based on a template that
-# is filled with values either passed to the script or calculated
-# at runtime.
-#
-# The script outputs a plain text file with the announcement text:
-#
-#   release-announcement-gerrit-X.Y.txt
-#
-# and, if GPG is available, the announcement text wrapped with a
-# signature:
-#
-#   release-announcement-gerrit-X.Y.txt.asc
-#
-# Usage:
-#
-#   ./tools/release-announcement.py -v 2.14.2 -p 2.14.1 \
-#      -s "This release fixes several bugs since 2.14.1"
-#
-# Parameters:
-#
-#   --version (-v): The version of Gerrit being released.
-#
-#   --previous (-p): The previous version of Gerrit.  Optional. If
-#   specified, the generated text includes a link to the gitiles
-#   log of commits between the previous and new versions.
-#
-#   --summary (-s): Short summary of the release. Optional. When
-#   specified, the summary is inserted in the introductory sentence
-#   of the generated text.
-#
-# Prerequisites:
-#
-# - The Jinja2 python library [1] must be installed.
-#
-# - For GPG signing to work, the python-gnupg library [2] must be
-#   installed, and the ~/.gnupg folder must exist.
-#
-# - The war file must have been installed to the local Maven repository
-#   using the `./tools/mvn/api.sh war_install` command.
-#
-# [1] http://jinja.pocoo.org/
-# [2] http://pythonhosted.org/gnupg/
-
-
-from __future__ import print_function
-import argparse
-import hashlib
-import os
-import sys
-from gnupg import GPG
-from jinja2 import Template
-
-
-class Version:
-    def __init__(self, version):
-        self.version = version
-        parts = version.split('.')
-        if len(parts) > 2:
-            self.major = ".".join(parts[:2])
-            self.patch = version
-        else:
-            self.major = version
-            self.patch = None
-
-    def __str__(self):
-        return self.version
-
-
-def _main():
-    descr = 'Generate Gerrit release announcement email text'
-    parser = argparse.ArgumentParser(
-        description=descr,
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    parser.add_argument('-v', '--version', dest='version',
-                        required=True,
-                        help='gerrit version to release')
-    parser.add_argument('-p', '--previous', dest='previous',
-                        help='previous gerrit version (optional)')
-    parser.add_argument('-s', '--summary', dest='summary',
-                        help='summary of the release content (optional)')
-    options = parser.parse_args()
-
-    summary = options.summary
-    if summary and not summary.endswith("."):
-        summary = summary + "."
-
-    data = {
-        "version": Version(options.version),
-        "previous": options.previous,
-        "summary": summary
-    }
-
-    war = os.path.join(
-        os.path.expanduser("~/.m2/repository/com/google/gerrit/gerrit-war/"),
-        "%(version)s/gerrit-war-%(version)s.war" % data)
-    if not os.path.isfile(war):
-        print("Could not find war file for Gerrit %s in local Maven repository"
-              % data["version"], file=sys.stderr)
-        sys.exit(1)
-
-    md5 = hashlib.md5()
-    sha1 = hashlib.sha1()
-    sha256 = hashlib.sha256()
-    BUF_SIZE = 65536  # Read data in 64kb chunks
-    with open(war, 'rb') as f:
-        while True:
-            d = f.read(BUF_SIZE)
-            if not d:
-                break
-            md5.update(d)
-            sha1.update(d)
-            sha256.update(d)
-
-    data["sha1"] = sha1.hexdigest()
-    data["sha256"] = sha256.hexdigest()
-    data["md5"] = md5.hexdigest()
-
-    template = Template(open("tools/release-announcement-template.txt").read())
-    output = template.render(data=data)
-
-    filename = "release-announcement-gerrit-%s.txt" % data["version"]
-    with open(filename, "w") as f:
-        f.write(output)
-
-    gpghome = os.path.abspath(os.path.expanduser("~/.gnupg"))
-    if not os.path.isdir(gpghome):
-        print("Skipping signing due to missing gnupg home folder")
-    else:
-        try:
-            gpg = GPG(homedir=gpghome)
-        except TypeError:
-            gpg = GPG(gnupghome=gpghome)
-        signed = gpg.sign(output)
-        filename = filename + ".asc"
-        with open(filename, "w") as f:
-            f.write(str(signed))
-
-
-if __name__ == "__main__":
-    _main()