Merge "Expose instanceName in the subject template"
diff --git a/.mailmap b/.mailmap
index f5f8f3e..4c71059 100644
--- a/.mailmap
+++ b/.mailmap
@@ -10,6 +10,7 @@
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
+Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
 Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
@@ -67,6 +68,7 @@
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
+Viktar Donich <viktard@google.com>                                                          viktard
 Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
 Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
 飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 72e309a..397b99a 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1380,6 +1380,13 @@
 link:cmd-stream-events.html[stream Gerrit events via ssh].
 
 
+[[capability_viewAccess]]
+=== View Access
+
+Allow checking access rights for arbitrary (user, project) pairs,
+using the link:rest-api-projects.html#check-access[check.access]
+endpoint
+
 [[capability_viewAllAccounts]]
 === View All Accounts
 
diff --git a/Documentation/cmd-index-activate.txt b/Documentation/cmd-index-activate.txt
index 418e872..4428d12 100644
--- a/Documentation/cmd-index-activate.txt
+++ b/Documentation/cmd-index-activate.txt
@@ -31,12 +31,13 @@
   Currently supported values:
     * changes
     * accounts
+    * groups
 
 == EXAMPLES
 Activate the latest change index:
 
 ----
-  $ ssh -p 29418 review.example.com gerrit activate changes
+  $ ssh -p 29418 review.example.com gerrit index activate changes
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index eed2eb4..f535281 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -169,6 +169,9 @@
 link:cmd-plugin-remove.html[gerrit plugin rm]::
 	Alias for 'gerrit plugin remove'.
 
+link:cmd-reload-config.html[gerrit reload-config]::
+	Apply an updated gerrit.config.
+
 link:cmd-set-account.html[gerrit set-account]::
 	Change an account's settings.
 
diff --git a/Documentation/cmd-reload-config.txt b/Documentation/cmd-reload-config.txt
new file mode 100644
index 0000000..7a25130
--- /dev/null
+++ b/Documentation/cmd-reload-config.txt
@@ -0,0 +1,44 @@
+= plugin reload
+
+== NAME
+reload-config - Reloads the gerrit.config.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit reload-config_
+  <NAME> ...
+--
+
+== DESCRIPTION
+Reloads the gerrit.config configuration.
+
+Not all configuration values can be picked up by this command. Which config
+sections and values that are supported is documented here:
+link:config-gerrit.html[Configuration]
+
+_The output shows only modified config values that are picked up by Gerrit
+and applied._
+
+If a config entry is added or removed from gerrit.config, but still brings
+no effect due to a matching default value, no output for this entry is shown.
+
+== ACCESS
+* Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== EXAMPLES
+Reload the gerrit configuration:
+
+----
+	ssh -p 29418 localhost gerrit reload-config
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index a75f610..72a9c21 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -264,7 +264,7 @@
 
 === Work In Progress State Changed
 
-Sent when the the link:intro-user.html#wip[WIP] state of the change has changed.
+Sent when the link:intro-user.html#wip[WIP] state of the change has changed.
 
 type:: wip-state-changed
 
@@ -277,7 +277,7 @@
 
 === Private State Changed
 
-Sent when the the link:intro-user.html#private-changes[private] state of the
+Sent when the link:intro-user.html#private-changes[private] state of the
 change has changed.
 
 type:: private-state-changed
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3c556c4..cadab83 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -7,8 +7,9 @@
 
 [NOTE]
 The contents of the `etc/gerrit.config` file are cached at startup
-by Gerrit.  If you modify any properties in this file, Gerrit needs
-to be restarted before it will use the new values.
+by Gerrit. For most properties, if they are modified in this file, Gerrit
+needs to be restarted before it will use the new values. Some properties
+support being link:#reloadConfig[`reloaded`]' without restart.
 
 Sample `etc/gerrit.config`:
 ----
@@ -19,6 +20,14 @@
   directory = /var/cache/gerrit
 ----
 
+[[reloadConfig]]
+=== Reload `etc/gerrit.config`
+Some properties support being reloaded without restart when a `reload config`
+command is issued through link:cmd-reload-config.html[`SSH`] or the
+link:rest-api-config.html#reload-config[`REST API`]. If a property supports
+this it is specified in the documentation for the property below.
+
+
 [[accountPatchReviewDb]]
 === Section accountPatchReviewDb
 
@@ -127,6 +136,8 @@
 This setting only applies for adding reviewers in the Gerrit Web UI,
 but is ignored when adding reviewers with the
 link:cmd-set-reviewers.html[set-reviewers] command.
++
+This value supports link:#reloadConfig[configuration reloads].
 
 [[addreviewer.maxAllowed]]addreviewer.maxAllowed::
 +
@@ -137,6 +148,8 @@
 be added at once by adding a group as reviewer.
 +
 Default is 20.
++
+This value supports link:#reloadConfig[configuration reloads].
 
 [[addReviewer.baseWeight]]addReviewer.baseWeight::
 +
@@ -1245,6 +1258,14 @@
 +
 The default limit is 1024kB.
 
+[[change.strictLabels]]change.strictLabels::
++
+Reject invalid label votes: invalid labels or invalid values. This
+configuration option is provided for backwards compaitbility and may
+be removed in future gerrit versions.
++
+Default is false.
+
 [[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
 If set to true, users are not allowed to create private changes.
@@ -1320,6 +1341,10 @@
 configuration 'tracker' uses raw HTML to more precisely control
 how the replacement is displayed to the user.
 
+commentlinks supports link:#reloadConfig[configuration reloads]. Though a
+link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
+commentlinks to be immediately available in the UI.
+
 ----
 [commentlink "changeid"]
   match = (I[0-9a-f]{8,40})
@@ -2167,6 +2192,19 @@
 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
+per-server specific account IDs.
++
+If this value is not set on startup it is automatically set to a random UUID.
++
+[NOTE]
+If this value doesn't match the serverId used when creating an already existing
+NoteDb, Gerrit will not be able to use that instance of NoteDb. The serverId
+used to create the NoteDb will show in the resulting exception message in case
+the value differs.
+
 [[gitweb]]
 === Section gitweb
 
@@ -2686,7 +2724,9 @@
 +
 * `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
 +
-An link:http://www.elasticsearch.org/[Elasticsearch] index is used.
+An link:https://www.elastic.co/products/elasticsearch[Elasticsearch] index is
+used. Refer to the link:#elasticsearch[Elasticsearch section] for further
+configuration details.
 
 +
 By default, `LUCENE`.
@@ -2728,9 +2768,10 @@
 +
 When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
 the `index.max_result_window` value configured on the Elasticsearch
-server.
+server. If a value is not configured during site initialization, defaults to
+10000, which is the default value of `index.max_result_window` in Elasticsearch.
 +
-Defaults to no limit.
+When `index.type` is set to `LUCENE`, defaults to no limit.
 
 [[index.maxPages]]index.maxPages::
 +
@@ -2896,8 +2937,8 @@
 [[elasticsearch]]
 === Section elasticsearch
 
-WARNING: The Elasticsearch support is incomplete. Online reindexing
-is still considered as beta.
+WARNING: The Elasticsearch support has only been tested with Elasticsearch
+version 2.4.x. 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.
@@ -3884,6 +3925,15 @@
 +
 Default is 1.
 
+[[execution.fanOutThreadPoolSize]]execution.fanOutThreadPoolSize::
++
+Maximum size of thread pool to on which a serving thread can fan-out
+work to parallelize it.
++
+When set to 0, a direct executor will be used.
++
+By default, 25 which means that formatting happens in the caller thread.
+
 [[receiveemail]]
 === Section receiveemail
 
@@ -4509,6 +4559,8 @@
 programmatic configuration.
 +
 By default, `true`.
++
+This value supports link:#reloadConfig[configuration reloads].
 
 [[sshd.rekeyBytesLimit]]sshd.rekeyBytesLimit::
 +
@@ -4536,6 +4588,8 @@
 The maximum numbers of reviewers suggested.
 +
 By default 10.
++
+This value supports link:#reloadConfig[configuration reloads].
 
 [[suggest.from]]suggest.from::
 +
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 6272b54..91e20cd 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -161,7 +161,9 @@
 the parent definition must be redefined in the child.
 
 To remove a label in a child project, add an empty label with the same
-name as in the parent.
+name as in the parent. This will override the parent label with
+a label containing the defaults (`function = MaxWithBlock`,
+`defaultValue = 0` and no further allowed values)
 
 [[label_layout]]
 === Layout
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 3e52f16..89c4b84 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -10,6 +10,9 @@
 * Python 2 or 3
 * Node.js
 * link:https://www.bazel.io/versions/master/docs/install.html[Bazel]
+* Maven
+* zip, unzip
+* gcc
 
 [[build]]
 == Building on the Command Line
@@ -390,6 +393,7 @@
 build --experimental_local_disk_cache_path=/home/<user>/.gerritcodereview/bazel-cache/cas
 build --experimental_local_disk_cache
 build --experimental_strict_action_env
+build --action_env=PATH
 ----
 
 [NOTE] `experimental_local_disk_cache_path` must be absolute path. Expansion of `~` is
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index ee07880..c6cadbb 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -164,7 +164,7 @@
 
 To format Java source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
-tool (version 1.3), and to format Bazel BUILD and WORKSPACE files the
+tool (version 1.5), and to format Bazel BUILD and WORKSPACE files the
 link:https://github.com/bazelbuild/buildifier[`buildifier`] tool (version 0.6.0).
 These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 6ce7f1f..f5042a7 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -810,7 +810,7 @@
 and `Edit Config` buttons on the project screen, and the `Follow-Up`
 button on the change screen).
 
-- [[publish-comments-on-push]]`Publish Draft Comments When a Change Is Updated by Push`:
+- [[publish-comments-on-push]]`Publish comments on push`:
 +
 Whether to publish any outstanding draft comments by default when pushing
 updates to open changes. This preference just sets the default; the behavior can
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 7360bd4..3b8a8cb 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -185,14 +185,16 @@
 
 [[requirement]]
 == requirement
-Information about a requirement (not met) in order to submit a change.
+Information about a requirement in order to submit a change.
 
-shortReason:: A short description of the requirement (a hint).
+fallbackText:: A human readable description of the requirement.
 
-fullReason:: A longer and descriptive message explaining what needs to
-be changed to meet the requirement.
+type:: Alphanumerical (plus hyphens or underscores) string to identify what the requirement is and
+why it was triggered. Can be seen as a class: requirements sharing the same type were created for a
+similar reason, and the data structure will follow one set of rules.
 
-label:: (Optional) The name of the linked label, if set by a pre-submit rule.
+data:: (Optional) Additional key-value data linked to this requirement. This is used in templates to
+render rich status messages.
 
 [[label]]
 == label
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 229c463..fa65a0b 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -55,6 +55,12 @@
 * `http/server/rest_api/server_latency`: REST API call latency by view.
 * `http/server/rest_api/response_bytes`: Size of REST API response on network
 (may be gzip compressed) by view.
+* `http/server/rest_api/change_json/to_change_info_latency`: Latency for
+toChangeInfo invocations in ChangeJson.
+* `http/server/rest_api/change_json/to_change_infos_latency`: Latency for
+toChangeInfos invocations in ChangeJson.
+* `http/server/rest_api/change_json/format_query_results_latency`: Latency for
+formatQueryResults invocations in ChangeJson.
 
 === Query
 
diff --git a/Documentation/pg-plugin-change-metadata-api.txt b/Documentation/pg-plugin-change-metadata-api.txt
new file mode 100644
index 0000000..8348da8
--- /dev/null
+++ b/Documentation/pg-plugin-change-metadata-api.txt
@@ -0,0 +1,16 @@
+= Gerrit Code Review - Change metadata plugin API
+
+This API is provided by
+link:pg-plugin-dev.html#change-metadata[plugin.changeMetadata()] and provides
+interface for customization and data updates of change metadata.
+
+== onLabelsChanged
+`changeMetadataApi.onLabelsChanged(callback)`
+
+.Params
+- *callback* function that's executed when labels changed on the server.
+Callback receives labels with scores applied to the change, map of the label
+names to link:rest-api-changes.html#label-info[LabelInfo] entries
+
+.Returns
+- `GrChangeMetadataApi` for chaining.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 580166a..c7aa57c 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -289,7 +289,18 @@
 
 Note: TODO
 
-[plugin-repo]
+[[plugin-rest-api]]
+=== restApi
+`plugin.restApi(opt_prefix)`
+
+.Params:
+- (optional) URL prefix, for easy switching into plugin URL space,
+  e.g. `changes/1/revisions/1/cookbook~say-hello`
+
+.Returns:
+- Instance of link:pg-plugin-rest-api.html[GrPluginRestApi].
+
+[[plugin-repo]]
 === repo
 `plugin.repo()`
 
@@ -341,6 +352,15 @@
 
 Deprecated. Use link:#plugin-settings[`plugin.settings()`] instead.
 
+=== changeMetadata
+`plugin.changeMetadata()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-change-metadata-api.html[GrChangeMetadataApi].
+
 === theme
 `plugin.theme()`
 
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index d3d0a8d..b77a66b 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -69,6 +69,11 @@
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
 
+* `labels`
++
+labels with scores applied to the change, map of the label names to
+link:rest-api-changes.html#label-info[LabelInfo] entries
+
 === robot-comment-controls
 The `robot-comment-controls` extension point is located inside each comment
 rendered on the diff page, and is only visible when the comment is a robot
@@ -118,4 +123,4 @@
 This endpoint decorator wraps the voting buttons in the reply dialog.
 
 === header-title
-This endpoint wraps the title-text in the application header.
\ No newline at end of file
+This endpoint wraps the title-text in the application header.
diff --git a/Documentation/pg-plugin-rest-api.txt b/Documentation/pg-plugin-rest-api.txt
new file mode 100644
index 0000000..70487ef
--- /dev/null
+++ b/Documentation/pg-plugin-rest-api.txt
@@ -0,0 +1,104 @@
+= Gerrit Code Review - Repo admin customization API
+
+This API is provided by link:pg-plugin-dev.html#plugin-rest-api[plugin.restApi()]
+and provides interface for Gerrit REST API.
+
+== getLoggedIn
+`repoApi.getLoggedIn()`
+
+Get user logged in status.
+
+.Params
+- None
+
+.Returns
+- Promise<boolean>
+
+== getVersion
+`repoApi.getVersion()`
+
+Get server version.
+
+.Params
+- None
+
+.Returns
+- Promise<string>
+
+== get
+`repoApi.get(url)`
+
+Issues a GET REST API call to the URL, returns Promise that is resolved to
+parsed response on success. Returned Promise is rejected on network error.
+
+.Params
+- *url* String URL without base path or plugin prefix.
+
+.Returns
+- Promise<Object> Parsed response.
+
+== post
+`repoApi.post(url, opt_payload)`
+
+Issues a POST REST API call to the URL, returns Promise that is resolved to
+parsed response on success. Returned Promise is rejected on network error.
+
+.Params
+- *url* String URL without base path or plugin prefix.
+- *opt_payload* (optional) Object Payload to be sent with the request.
+
+.Returns
+- Promise<Object> Parsed response.
+
+== put
+`repoApi.put(url, opt_payload)`
+
+Issues a PUT REST API call to the URL, returns Promise that is resolved to
+parsed response on success. Returned Promise is rejected on network error.
+
+.Params
+- *url* String URL without base path or plugin prefix.
+- *opt_payload* (optional) Object Payload to be sent with the request.
+
+.Returns
+- Promise<Object> Parsed response.
+
+== delete
+`repoApi.delete(url)`
+
+Issues a DELETE REST API call to the URL, returns Promise that is resolved to
+parsed response on HTTP 204, and rejected otherwise.
+
+.Params
+- *url* String URL without base path or plugin prefix.
+
+.Returns
+- Promise<Response> Fetch API's Response object.
+
+== send
+`repoApi.send(method, url, opt_payload)`
+
+Send payload and parse the response, if request succeeds. Returned Promise is
+rejected with detailed message or HTTP error code on network error.
+
+.Params
+- *method* String HTTP method.
+- *url* String URL without base path or plugin prefix.
+- *opt_payload* (optional) Object Respected for POST and PUT only.
+
+.Returns
+- Promise<Object> Parsed response.
+
+== fetch
+`repoApi.fetch(method, url, opt_payload)`
+
+Send payload and return native Response. This method is for low-level access, to
+implement custom error handling and parsing.
+
+.Params
+- *method* String HTTP method.
+- *url* String URL without base path or plugin prefix.
+- *opt_payload* (optional) Object Respected for POST and PUT only.
+
+.Returns
+- Promise<Response> Fetch API's Response object.
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 386e2d6..4e3c428 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -901,7 +901,10 @@
     findall(X, gerrit:commit_label(label(Category,X),R),Z),
     sum_list(Z, Sum),
     Sum >= Min, !,
-    P = [label(Category,ok(R)) | In].
+    gerrit:commit_label(label(Category, V), U),
+    V >= 1,
+    !,
+    P = [label(Category,ok(U)) | In].
 
 add_category_min_score(In, Category,Min,P) :-
     P = [label(Category,need(Min)) | In].
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 3c0f1e0..025b29d 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1765,6 +1765,89 @@
   HTTP/1.1 204 No Content
 ----
 
+[[list-contributor-agreements]]
+=== List Contributor Agreements
+--
+'GET /accounts/link:#account-id[\{account-id\}]/agreements'
+--
+
+Gets a list of the user's signed contributor agreements.
+
+.Request
+----
+  GET /a/accounts/self/agreements HTTP/1.0
+----
+
+As response the user's signed agreements are returned as a list
+of link:#contributor-agreement-info[ContributorAgreementInfo] entities.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Individual",
+      "description": "If you are going to be contributing code on your own, this is the one you want. You can sign this one online.",
+      "url": "static/cla_individual.html"
+    }
+  ]
+----
+
+[[sign-contributor-agreement]]
+=== Sign Contributor Agreement
+--
+'PUT /accounts/link:#account-id[\{account-id\}]/agreements'
+--
+
+Signs a contributor agreement.
+
+The contributor agreement must be provided in the request body as
+a link:#contributor-agreement-input[ContributorAgreementInput].
+
+.Request
+----
+  PUT /accounts/self/agreements HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Individual"
+  }
+----
+
+As response the contributor agreement name is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Individual"
+----
+
+[[index-account]]
+=== Index Account
+--
+'POST /accounts/link:#account-id[\{account-id\}]/index'
+--
+
+Adds or updates the account in the secondary index.
+
+.Request
+----
+  POST /accounts/1000096/index HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[default-star-endpoints]]
 == Default Star Endpoints
 
@@ -1981,89 +2064,6 @@
   ]
 ----
 
-[[list-contributor-agreements]]
-=== List Contributor Agreements
---
-'GET /accounts/link:#account-id[\{account-id\}]/agreements'
---
-
-Gets a list of the user's signed contributor agreements.
-
-.Request
-----
-  GET /a/accounts/self/agreements HTTP/1.0
-----
-
-As response the user's signed agreements are returned as a list
-of link:#contributor-agreement-info[ContributorAgreementInfo] entities.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    {
-      "name": "Individual",
-      "description": "If you are going to be contributing code on your own, this is the one you want. You can sign this one online.",
-      "url": "static/cla_individual.html"
-    }
-  ]
-----
-
-[[sign-contributor-agreement]]
-=== Sign Contributor Agreement
---
-'PUT /accounts/link:#account-id[\{account-id\}]/agreements'
---
-
-Signs a contributor agreement.
-
-The contributor agreement must be provided in the request body as
-a link:#contributor-agreement-input[ContributorAgreementInput].
-
-.Request
-----
-  PUT /accounts/self/agreements HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "name": "Individual"
-  }
-----
-
-As response the contributor agreement name is returned.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Individual"
-----
-
-[[index-account]]
-=== Index Account
---
-'POST /accounts/link:#account-id[\{account-id\}]/index'
---
-
-Adds or updates the account in the secondary index.
-
-.Request
-----
-  POST /accounts/1000096/index HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
 [[ids]]
 == IDs
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8f889ac..9a09836 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5642,6 +5642,9 @@
 Actions the caller might be able to perform on this revision. The
 information is a map of view name to link:#action-info[ActionInfo]
 entities.
+|`requirements`             |optional|
+List of the link:rest-api-changes.html#requirement[requirements] to be met before this change
+can be submitted.
 |`labels`             |optional|
 The labels of the change as a map that maps the label names to
 link:#label-info[LabelInfo] entries. +
@@ -6591,6 +6594,32 @@
 oldest. Empty if there are no related changes.
 |===========================
 
+
+[[requirement]]
+=== Requirement
+The `Requirement` entity contains information about a requirement relative to a change.
+
+type:: Alphanumerical (plus hyphens or underscores) string to identify what the requirement is and
+why it was triggered. Can be seen as a class: requirements sharing the same type were created for a
+similar reason, and the data structure will follow one set of rules.
+
+data:: (Optional) Additional key-value data linked to this requirement. This is used in templates to
+render rich status messages.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      | |Description
+|`status`        | | Status of the requirement. Can be either `OK`, `NOT_READY` or `RULE_ERROR`.
+|`fallbackText`  | | A human readable reason
+|`type`          | |
+Alphanumerical (plus hyphens or underscores) string to identify what the requirement is and why it
+was triggered. Can be seen as a class: requirements sharing the same type were created for a similar
+reason, and the data structure will follow one set of rules.
+|`data`          |optional|
+Holds custom key-value strings, used in templates to render richer status messages
+|===========================
+
+
 [[restore-input]]
 === RestoreInput
 The `RestoreInput` entity contains information for restoring a change.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 3d18abb..59a4608 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -194,6 +194,51 @@
 ----
 
 
+[[reload-config]]
+=== Reload Config
+--
+'POST /config/server/reload'
+--
+
+Reloads the gerrit.config configuration.
+
+Not all configuration value can be picked up by this command. Which config
+sections and values that are supported is documented here:
+link:config-gerrit.html[Configuration]
+
+_The output shows only modified config values that are picked up by Gerrit
+and applied._
+
+If a config entry is added or removed from gerrit.config, but still brings
+no effect due to a matching default value, no output for this entry is shown.
+
+.Request
+----
+  POST /config/server/reload HTTP/1.0
+----
+
+As result a link:#config-update-info[ConfigUpdateInfo] entity is returned that
+contains information about how the updated config entries were handled.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rejected": [],
+    "applied": [
+      {
+        "config_key": "addreviewer.maxAllowed",
+        "old_value": "20",
+        "new_value": "15"
+      }
+    ]
+  }
+----
+
+
 [[confirm-email]]
 === Confirm Email
 --
@@ -1609,6 +1654,39 @@
 |`message` |Message describing the consistency problem.
 |======================
 
+[[config-update-info]]
+=== ConfigUpdateInfo
+The entity describes the result of a reload of gerrit.config.
+
+If a changed config value is missing from the `applied` and the `rejected`
+lists there are no guarantees to whether they have or have not taken effect.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`applied` |A list of link:#config-update-entry-info[ConfigUpdateEntryInfos]
+describing the applied configuration changes. +
+Every config value change representation present in this list is guaranteed to
+have taken effect.
+|`rejected` |A list of link:#config-update-entry-info[ConfigUpdateEntryInfos]
+describing the rejected configuration changes.  +
+Every config value change representation present in this list is guaranteed not
+to have taken effect.
+|======================
+
+[[config-update-entry-info]]
+=== ConfigUpdateEntryInfo
+The entity describes an updated config value.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`config_key` |The config key that contains the value.
+|`old_value`  |The old config value. +
+Missing if value was not previously configured.
+|`new_value`  |The new config value, picked up after reload.
+|======================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 0ae3a64..bc5a3c6 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1311,7 +1311,7 @@
 --
 
 Runs access checks for other users. This requires the
-link:access-control.html#capability_administrateServer[Administrate Server]
+link:access-control.html#capability_viewAccess[View Access]
 global capability.
 
 Input for the access checks that should be run must be provided in
@@ -1345,6 +1345,14 @@
   }
 ----
 
+This endpoint can also be accessed as a GET request, using the query
+parameters `perm`, `account` and `ref`, for example:
+
+----
+  GET /projects/MyProject/check.access?account=10024&ref=refs/heads/secret/bla
+----
+
+
 [[index]]
 === Index all changes in a project
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 2ad8cbd..bebe81b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -31,13 +31,15 @@
 |Full or abbreviated Change-Id    | Ic0ff33
 |Full or abbreviated commit SHA-1 | d81b32ef
 |Email address                    | user@example.com
-|Approval requirement             | Code-Review>=+2, Verified=1
 |=============================================================
 
 For change searches (i.e. those using a numerical id, Change-Id, or commit
 SHA1), if the search results in a single change that change will be
 presented instead of a list.
 
+For more predictable results, use explicit search operators as described
+in the following section.
+
 [[search-operators]]
 == Search Operators
 
@@ -338,6 +340,11 @@
 True on any change where the current user is a reviewer.
 Same as `reviewer:self`.
 
+is:cc::
++
+True on any change where the current user is in CC.
+Same as `cc:self`.
+
 is:open, is:pending::
 +
 True if the change is open.
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt
index 13d0755..a2a080b 100644
--- a/Documentation/user-submodules.txt
+++ b/Documentation/user-submodules.txt
@@ -27,7 +27,7 @@
 .. a url that starts with the link:config-gerrit.html#gerrit.canonicalWebUrl[`gerrit.canonicalWebUrl`]
 
 When a commit in a project is merged, Gerrit checks for superprojects
-that are subscribed to the the project and automatically updates those
+that are subscribed to the project and automatically updates those
 superprojects with a commit that updates the gitlink for the project.
 
 This feature is enabled by default and can be disabled
diff --git a/WORKSPACE b/WORKSPACE
index d782c23..4d1f81b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -415,10 +415,18 @@
     sha1 = "430b2fc839b5de1f3643b528853d5cf26096c1de",
 )
 
+AUTO_VALUE_VERSION = "1.6"
+
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.5.4",
-    sha1 = "65183ddd1e9542d69d8f613fdae91540d04e1476",
+    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+    sha1 = "a3b1b1404f8acaa88594a017185e013cd342c9a8",
+)
+
+maven_jar(
+    name = "auto_value_annotations",
+    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+    sha1 = "da725083ee79fdcd86d9f3d8a76e38174a01892a",
 )
 
 # Transitive dependency of commons-compress
@@ -444,12 +452,6 @@
 )
 
 maven_jar(
-    name = "lucene_codecs",
-    artifact = "org.apache.lucene:lucene-codecs:" + LUCENE_VERS,
-    sha1 = "afdad570668469b1734fbd32b8f98561561bed48",
-)
-
-maven_jar(
     name = "backward_codecs",
     artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
     sha1 = "a933f42e758c54c43083398127ea7342b54d8212",
@@ -486,12 +488,6 @@
 )
 
 maven_jar(
-    name = "lucene_sandbox",
-    artifact = "org.apache.lucene:lucene-sandbox:" + LUCENE_VERS,
-    sha1 = "49498bbb2adc333e98bdca4bf6170ae770cbad11",
-)
-
-maven_jar(
     name = "lucene_spatial",
     artifact = "org.apache.lucene:lucene-spatial:" + LUCENE_VERS,
     sha1 = "0217d302dc0ef4d9b8b475ffe327d83c1e0ceba5",
@@ -566,17 +562,17 @@
 
 maven_jar(
     name = "blame_cache",
-    artifact = "com/google/gitiles:blame-cache:0.2-5",
+    artifact = "com/google/gitiles:blame-cache:0.2-6",
     attach_source = False,
     repository = GERRIT,
-    sha1 = "50861b114350c598579ba66f99285e692e3c8d45",
+    sha1 = "64827f1bc2cbdbb6515f1d29ce115db94c03bb6a",
 )
 
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2018-01-03",
-    sha1 = "62089a55675f338bdfb41fba1b29fe610f654b4d",
+    artifact = "com.google.template:soy:2018-03-14",
+    sha1 = "76a1322705ba5a6d6329ee26e7387417725ce4b3",
 )
 
 maven_jar(
@@ -967,12 +963,6 @@
     sha1 = "84ccf145ac2215e6bfa63baa3101c0af41017cfc",
 )
 
-maven_jar(
-    name = "jna",
-    artifact = "net.java.dev.jna:jna:4.1.0",
-    sha1 = "1c12d070e602efd8021891cdd7fd18bc129372d4",
-)
-
 JACKSON_VERSION = "2.8.9"
 
 maven_jar(
@@ -982,18 +972,18 @@
 )
 
 maven_jar(
-    name = "jackson_dataformat_smile",
-    artifact = "com.fasterxml.jackson.dataformat:jackson-dataformat-smile:" + JACKSON_VERSION,
-    sha1 = "d36cbae6b06ac12fca16fda403759e479316141b",
-)
-
-maven_jar(
     name = "jackson_dataformat_cbor",
     artifact = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:" + JACKSON_VERSION,
     sha1 = "93242092324cad33d777e06c0515e40a6b862659",
 )
 
 maven_jar(
+    name = "jackson_dataformat_smile",
+    artifact = "com.fasterxml.jackson.dataformat:jackson-dataformat-smile:" + JACKSON_VERSION,
+    sha1 = "d36cbae6b06ac12fca16fda403759e479316141b",
+)
+
+maven_jar(
     name = "httpasyncclient",
     artifact = "org.apache.httpcomponents:httpasyncclient:4.1.2",
     sha1 = "95aa3e6fb520191a0970a73cf09f62948ee614be",
@@ -1005,13 +995,6 @@
     sha1 = "a8c5e3c3bfea5ce23fb647c335897e415eb442e3",
 )
 
-maven_jar(
-    name = "httpcore_niossl",
-    artifact = "org.apache.httpcomponents:httpcore-niossl:4.0-alpha6",
-    attach_source = False,
-    sha1 = "9c662e7247ca8ceb1de5de629f685c9ef3e4ab58",
-)
-
 load("//tools/bzl:js.bzl", "npm_binary", "bower_archive")
 
 npm_binary(
diff --git a/contrib/git-push-review b/contrib/git-push-review
index 87eaa4c..b995fc2 100755
--- a/contrib/git-push-review
+++ b/contrib/git-push-review
@@ -50,6 +50,10 @@
                  help='reviewer names or aliases, or #hashtags')
   p.add_argument('-t', '--topic', default='', metavar='TOPIC',
                  help='topic for new changes')
+  p.add_argument('-e', '--edit', action='store_true',
+                 help='upload as change edit')
+  p.add_argument('-w', '--wip', action='store_true', help='upload as WIP')
+  p.add_argument('-y', '--ready', action='store_true', help='set ready')
   p.add_argument('--dry-run', action='store_true',
                  help='dry run, print git command and exit')
   args = p.parse_args()
@@ -77,7 +81,22 @@
   opts['t'].extend(t[1:] for t in args.args if is_hashtag(t))
   if args.topic:
     opts['topic'].append(args.topic)
-  opts_str = ','.join('%s=%s' % (k, v) for k in opts for v in opts[k])
+  if args.edit:
+    opts['edit'].append(True)
+  if args.wip:
+    opts['wip'].append(True)
+  if args.ready:
+    opts['ready'].append(True)
+
+  opts_strs = []
+  for k in opts:
+    for v in opts[k]:
+      if v == True:
+        opts_strs.append(k)
+      elif v != False:
+        opts_strs.append('%s=%s' % (k, v))
+
+  opts_str = ','.join(opts_strs)
   if opts_str:
     opts_str = '%' + opts_str
 
diff --git a/contrib/gitiles b/contrib/gitiles
new file mode 100755
index 0000000..3e603b9
--- /dev/null
+++ b/contrib/gitiles
@@ -0,0 +1,84 @@
+#!/bin/bash
+#
+# 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.
+
+usage() {
+  me=`basename "$0"`
+  echo >&2 "Usage: $me [open] [-b branch] [path]"
+  exit 1
+}
+
+cmd_open() {
+  case "$(uname)" in
+    Darwin)
+      echo "open"
+      ;;
+    Linux)
+      echo "xdg-open"
+      ;;
+
+    *)
+      echo >&2 "Don't know how to open URLs on $(uname)"
+      exit 1
+  esac
+}
+
+URL=$(git config --get gitiles.url)
+
+if test -z "$URL" ; then
+  echo >&2 "gitiles.url must be set in .git/config"
+  exit 1
+fi
+
+while test $# -gt 0 ; do
+  case "$1" in
+  open)
+    CMD=$(cmd_open)
+    shift
+    ;;
+  -b|--branch)
+    shift
+    B=$1
+    shift
+    ;;
+  -h|--help)
+    usage
+    ;;
+
+  *)
+    P=$1
+    shift
+  esac
+done
+
+if test -z "$CMD" ; then
+  CMD=echo
+fi
+
+if test -z "$B" ; then
+  B=$(git rev-parse HEAD)
+fi
+
+URL="$URL/+/$B"
+
+if test -z "$P" ; then
+  P=$(git rev-parse --show-prefix)
+elif test ${P:0:2} = "./" ; then
+  P=$(git rev-parse --show-prefix)${P:2}
+fi
+
+URL="$URL/$P"
+
+$CMD $URL
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 a975b29..ca7cc27 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
@@ -38,7 +38,7 @@
 showLegacycidInChangeTable = Show Change Number In Changes Table
 muteCommonPathPrefixes = Mute Common Path Prefixes In File List
 signedOffBy = Insert Signed-off-by Footer For Inline Edit Changes
-publishCommentsOnPush = Publish Draft Comments When a Change Is Updated by Push
+publishCommentsOnPush = Publish Comments On Push
 myMenu = My Menu
 myMenuInfo = \
   Menu items for the 'My' top level menu. \
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 b57545b..cb6fe28 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,7 +1259,7 @@
   }
 
   private void renderSubmitType(Change.Status status, boolean canSubmit, SubmitType submitType) {
-    if (canSubmit && status == Change.Status.NEW) {
+    if (canSubmit && status == Change.Status.NEW && !changeInfo.isWorkInProgress()) {
       statusText.setInnerText(
           changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index 9860cb2..2d5a9f9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -8,7 +8,7 @@
 notCurrent = Not Current
 changeEdit = Change Edit
 isPrivate = (Private)
-isWorkInProgress = (WorkInProgress)
+isWorkInProgress = (Work in Progress)
 
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
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 425fe69..caea87e 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
@@ -240,6 +240,11 @@
           row,
           C_STATUS,
           Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isWorkInProgress()) {
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.workInProgress() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
     } else if (!c.mergeable()) {
       table.setText(
           row,
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 8c38f5e..fbf3b84 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -27,7 +27,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.github.rholder.retry.BlockStrategy;
 import com.google.common.base.Strings;
@@ -37,6 +36,7 @@
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
@@ -61,7 +61,6 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
@@ -131,7 +130,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
-import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -253,6 +252,7 @@
   @Inject protected MutableNotesMigration notesMigration;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
+  @Inject protected TestSshKeys sshKeys;
 
   protected EventRecorder eventRecorder;
   protected GerritServer server;
@@ -334,14 +334,14 @@
         // Don't reset all refs so that refs/sequences/changes is not touched and change IDs are
         // not reused.
         .reset(allProjects, RefNames.REFS_CONFIG)
-        // Don't reset group branches since this would make the groups inconsistent between
-        // ReviewDb and NoteDb.
         // Don't reset refs/sequences/accounts so that account IDs are not reused.
         .reset(
             allUsers,
             RefNames.REFS_CONFIG,
             RefNames.REFS_USERS + "*",
             RefNames.REFS_EXTERNAL_IDS,
+            RefNames.REFS_GROUPNAMES,
+            RefNames.REFS_GROUPS + "*",
             RefNames.REFS_STARRED_CHANGES + "*",
             RefNames.REFS_DRAFT_COMMENTS + "*");
   }
@@ -441,7 +441,9 @@
 
     Context ctx = newRequestContext(admin);
     atrScope.set(ctx);
-    project = createProject(projectInput(description));
+    ProjectInput in = projectInput(description);
+    gApi.projects().create(in);
+    project = new Project.NameKey(in.name);
     testRepo = cloneProject(project, getCloneAsAccount(description));
   }
 
@@ -450,12 +452,13 @@
     return null;
   }
 
-  protected void initSsh() throws JSchException {
+  protected void initSsh() throws Exception {
     if (testRequiresSsh
         && SshMode.useSsh()
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
-      GitUtil.initSsh(admin);
+      KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
+      GitUtil.initSsh(adminKeyPair);
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -472,6 +475,7 @@
     return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
   }
 
+  /** Generate default project properties based on test description */
   private ProjectInput projectInput(Description description) {
     ProjectInput in = new ProjectInput();
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
@@ -493,6 +497,15 @@
     return in;
   }
 
+  /**
+   * Modify a project input before creating the initial test project.
+   *
+   * @param in input; may be modified in place.
+   */
+  protected void updateProjectInput(ProjectInput in) {
+    // Default implementation does nothing.
+  }
+
   private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
 
   protected Git git() {
@@ -534,12 +547,6 @@
   }
 
   protected Project.NameKey createProject(
-      String nameSuffix, Project.NameKey parent, SubmitType submitType) throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, true, submitType);
-  }
-
-  protected Project.NameKey createProject(
       String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
@@ -547,35 +554,14 @@
     in.parent = parent != null ? parent.get() : null;
     in.submitType = submitType;
     in.createEmptyCommit = createEmptyCommit;
-    return createProject(in);
-  }
-
-  private Project.NameKey createProject(ProjectInput in) throws RestApiException {
     gApi.projects().create(in);
     return new Project.NameKey(in.name);
   }
 
-  /**
-   * Modify a project input before creating the initial test project.
-   *
-   * @param in input; may be modified in place.
-   */
-  protected void updateProjectInput(ProjectInput in) {
-    // Default implementation does nothing.
-  }
-
   protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
     return cloneProject(p, admin);
   }
 
-  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p, String ref)
-      throws Exception {
-    TestRepository<InMemoryRepository> repo = cloneProject(p);
-    GitUtil.fetch(repo, ref + ":" + ref);
-    repo.reset(ref);
-    return repo;
-  }
-
   protected TestRepository<InMemoryRepository> cloneProject(
       Project.NameKey p, TestAccount testAccount) throws Exception {
     return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
@@ -706,10 +692,6 @@
     return result;
   }
 
-  protected PushOneCommit.Result createChangeWithTopic() throws Exception {
-    return createChangeWithTopic(testRepo, "topic", "message", "a.txt", "content\n");
-  }
-
   protected PushOneCommit.Result createChangeWithTopic(
       TestRepository<InMemoryRepository> repo,
       String topic,
@@ -722,10 +704,6 @@
         repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
   }
 
-  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
-    return pushTo("refs/for/master%wip");
-  }
-
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
@@ -734,13 +712,6 @@
   }
 
   protected PushOneCommit.Result createChange(
-      String subject, String fileName, String content, String topic) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master/" + name(topic));
-  }
-
-  protected PushOneCommit.Result createChange(
       TestRepository<?> repo,
       String branch,
       String subject,
@@ -752,10 +723,6 @@
     return push.to("refs/for/" + branch + "/" + name(topic));
   }
 
-  protected BranchApi createBranch(String branch) throws Exception {
-    return createBranch(new Branch.NameKey(project, branch));
-  }
-
   protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
     return gApi.projects()
         .name(branch.getParentKey().get())
@@ -774,11 +741,7 @@
       Chars.asList(new char[] {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
 
   protected PushOneCommit.Result amendChange(String changeId) throws Exception {
-    return amendChange(changeId, "refs/for/master");
-  }
-
-  protected PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
-    return amendChange(changeId, ref, admin, testRepo);
+    return amendChange(changeId, "refs/for/master", admin, testRepo);
   }
 
   protected PushOneCommit.Result amendChange(
@@ -838,7 +801,7 @@
   private Context newRequestContext(TestAccount account) {
     return atrScope.newContext(
         reviewDbProvider,
-        new SshSession(server, account),
+        new SshSession(sshKeys, server, account),
         identifiedUserFactory.create(account.getId()));
   }
 
@@ -929,9 +892,10 @@
 
   protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg, permission, id, ref);
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.allow(u.getConfig(), permission, id, ref);
+      u.save();
+    }
   }
 
   protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
@@ -941,11 +905,12 @@
 
   protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    for (String capabilityName : capabilityNames) {
-      Util.allow(cfg, capabilityName, id);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      for (String capabilityName : capabilityNames) {
+        Util.allow(u.getConfig(), capabilityName, id);
+      }
+      u.save();
     }
-    saveProjectConfig(allProjects, cfg);
   }
 
   protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
@@ -955,11 +920,12 @@
 
   protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    for (String capabilityName : capabilityNames) {
-      Util.remove(cfg, capabilityName, id);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      for (String capabilityName : capabilityNames) {
+        Util.remove(u.getConfig(), capabilityName, id);
+      }
+      u.save();
     }
-    saveProjectConfig(allProjects, cfg);
   }
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -995,9 +961,10 @@
 
   protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.deny(cfg, permission, id, ref);
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.deny(u.getConfig(), permission, id, ref);
+      u.save();
+    }
   }
 
   protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
@@ -1008,30 +975,20 @@
   protected PermissionRule block(
       Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    PermissionRule rule = Util.block(cfg, permission, id, ref);
-    saveProjectConfig(project, cfg);
-    return rule;
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      PermissionRule rule = Util.block(u.getConfig(), permission, id, ref);
+      u.save();
+      return rule;
+    }
   }
 
   protected void blockLabel(
       String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.LABEL + label, min, max, id, ref);
-    saveProjectConfig(project, cfg);
-  }
-
-  protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
-      md.setAuthor(identifiedUserFactory.create(admin.getId()));
-      cfg.commit(md);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.block(u.getConfig(), Permission.LABEL + label, min, max, id, ref);
+      u.save();
     }
-    projectCache.evict(cfg.getProject());
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    saveProjectConfig(project, cfg);
   }
 
   protected void grant(Project.NameKey project, String ref, String permission)
@@ -1108,12 +1065,6 @@
     block(ref, Permission.READ, REGISTERED_USERS);
   }
 
-  protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
-    saveProjectConfig(project, cfg);
-  }
-
   protected PushOneCommit.Result pushTo(String ref) throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     return push.to(ref);
@@ -1127,26 +1078,18 @@
     gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
   }
 
-  protected Map<String, ActionInfo> getActions(String id) throws Exception {
-    return gApi.changes().id(id).revision(1).actions();
-  }
-
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
-  private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
-    return Iterables.transform(changes, i -> i.changeId);
-  }
-
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
     List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
     SubmittedTogetherInfo info =
         gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
-    assertThat(changeIds(actual)).containsExactly((Object[]) expected).inOrder();
-    assertThat(changeIds(info.changes)).containsExactly((Object[]) expected).inOrder();
+    assertThat(Iterables.transform(actual, i1 -> i1.changeId))
+        .containsExactly((Object[]) expected)
+        .inOrder();
+    assertThat(Iterables.transform(info.changes, i -> i.changeId))
+        .containsExactly((Object[]) expected)
+        .inOrder();
   }
 
   protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
@@ -1200,18 +1143,6 @@
     return name;
   }
 
-  protected String createAccount(String name, String group) throws Exception {
-    name = name(name);
-    accountCreator.create(name, group);
-    return name;
-  }
-
-  protected TestAccount createUniqueAccount(String userName, String fullName) throws Exception {
-    String uniqueUserName = name(userName);
-    String uniqueFullName = name(fullName);
-    return accountCreator.create(uniqueUserName, uniqueUserName + "@example.com", uniqueFullName);
-  }
-
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
       Ref r = repo.exactRef(name);
@@ -1237,13 +1168,6 @@
     return getRemoteHead(project, "master");
   }
 
-  protected void grantTagPermissions() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
-    grant(project, R_TAGS + "", Permission.DELETE);
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
-  }
-
   protected void assertMailReplyTo(Message message, String email) throws Exception {
     assertThat(message.headers()).containsKey("Reply-To");
     EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
@@ -1270,22 +1194,15 @@
     ca.setDescription("description");
     ca.setAgreementUrl("agreement-url");
 
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    cfg.replace(ca);
-    saveProjectConfig(allProjects, cfg);
-    return ca;
-  }
-
-  protected BinaryResult submitPreview(String changeId) throws Exception {
-    return gApi.changes().id(changeId).current().submitPreview();
-  }
-
-  protected BinaryResult submitPreview(String changeId, String format) throws Exception {
-    return gApi.changes().id(changeId).current().submitPreview(format);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().replace(ca);
+      u.save();
+      return ca;
+    }
   }
 
   protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = submitPreview(changeId)) {
+    try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
       return fetchFromBundles(result);
     }
   }
@@ -1406,14 +1323,6 @@
     assertThat(contentEntry.skip).isNull();
   }
 
-  protected TestRepository<?> createProjectWithPush(
-      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
-    Project.NameKey project = createProject(name, parent, true, submitType);
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
-    return cloneProject(project);
-  }
-
   protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
     assertThat(info.permittedLabels).isNotNull();
     Collection<String> strs = info.permittedLabels.get(label);
@@ -1443,27 +1352,7 @@
     }
   }
 
-  protected void assertLabelPermission(
-      Project.NameKey project,
-      GroupReference groupReference,
-      String ref,
-      boolean exclusive,
-      String labelName,
-      int min,
-      int max)
-      throws IOException {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
-
-    String permissionName = Permission.LABEL + labelName;
-    Permission permission = accessSection.getPermission(permissionName);
-    assertPermission(permission, permissionName, exclusive, labelName);
-    assertPermissionRule(
-        permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
-  }
-
-  private void assertPermission(
+  protected void assertPermission(
       Permission permission,
       String expectedName,
       boolean expectedExclusive,
@@ -1474,7 +1363,7 @@
     assertThat(permission.getLabel()).isEqualTo(expectedLabelName);
   }
 
-  private void assertPermissionRule(
+  protected void assertPermissionRule(
       PermissionRule rule,
       GroupReference expectedGroupReference,
       Action expectedAction,
@@ -1533,15 +1422,21 @@
   }
 
   protected void assertNotifyTo(TestAccount expected) {
-    assertNotifyTo(expected.emailAddress);
+    assertNotifyTo(expected.email, expected.fullName);
   }
 
-  protected void assertNotifyTo(Address expected) {
+  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) {
+    Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected);
+    assertThat(m.rcpt()).containsExactly(expectedAddress);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected);
+        .containsExactly(expectedAddress);
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
@@ -1549,13 +1444,23 @@
     assertNotifyCc(expected.emailAddress);
   }
 
-  protected void assertNotifyCc(Address expected) {
+  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);
+  }
+
+  protected void assertNotifyCc(Address expectedAddress) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected);
+    assertThat(m.rcpt()).containsExactly(expectedAddress);
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(((EmailHeader.AddressList) m.headers().get("Cc")).getAddressList())
-        .containsExactly(expected);
+        .containsExactly(expectedAddress);
   }
 
   protected void assertNotifyBcc(TestAccount expected) {
@@ -1566,6 +1471,17 @@
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
+  protected void assertNotifyBcc(
+      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
+    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.headers().get("To").isEmpty()).isTrue();
+    assertThat(m.headers().get("Cc").isEmpty()).isTrue();
+  }
+
   protected interface ProjectWatchInfoConfiguration {
     void configure(ProjectWatchInfo pwi);
   }
@@ -1632,10 +1548,6 @@
     }
   }
 
-  protected RevCommit parseCurrentRevision(RevWalk rw, PushOneCommit.Result r) throws Exception {
-    return parseCurrentRevision(rw, r.getChangeId());
-  }
-
   protected RevCommit parseCurrentRevision(RevWalk rw, String changeId) throws Exception {
     return rw.parseCommit(
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
@@ -1649,27 +1561,59 @@
   protected void configLabel(
       Project.NameKey project, String label, LabelFunction func, LabelValue... value)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType labelType = category(label, value);
-    labelType.setFunction(func);
-    cfg.getLabelSections().put(labelType.getName(), labelType);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = category(label, value);
+      labelType.setFunction(func);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
   }
 
   protected void fail(@Nullable String format, Object... args) {
     assert_().fail(format, args);
   }
 
-  protected void fail() {
-    assert_().fail();
+  protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.TRUE);
+      u.save();
+    }
   }
 
-  protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(
-            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
+  protected ProjectConfigUpdate updateProject(Project.NameKey projectName) throws Exception {
+    return new ProjectConfigUpdate(projectName);
+  }
+
+  protected class ProjectConfigUpdate implements AutoCloseable {
+    private final ProjectConfig projectConfig;
+    private MetaDataUpdate metaDataUpdate;
+
+    private ProjectConfigUpdate(Project.NameKey projectName) throws Exception {
+      metaDataUpdate = metaDataUpdateFactory.create(projectName);
+      projectConfig = ProjectConfig.read(metaDataUpdate);
+    }
+
+    public ProjectConfig getConfig() {
+      return projectConfig;
+    }
+
+    public void save() throws Exception {
+      metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.getId()));
+      projectConfig.commit(metaDataUpdate);
+      metaDataUpdate.close();
+      metaDataUpdate = null;
+      projectCache.evict(projectConfig.getProject());
+    }
+
+    @Override
+    public void close() {
+      if (metaDataUpdate != null) {
+        metaDataUpdate.close();
+      }
+    }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c6e03a8..1416797 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -27,22 +26,15 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-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.Singleton;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -57,29 +49,20 @@
 
   private final Sequences sequences;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final GroupCache groupCache;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
-  private final SshKeyCache sshKeyCache;
-  private final boolean sshEnabled;
 
   @Inject
   AccountCreator(
       Sequences sequences,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      VersionedAuthorizedKeys.Accessor authorizedKeys,
       GroupCache groupCache,
-      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
-      SshKeyCache sshKeyCache,
-      @SshEnabled boolean sshEnabled) {
+      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     accounts = new HashMap<>();
     this.sequences = sequences;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.authorizedKeys = authorizedKeys;
     this.groupCache = groupCache;
     this.groupsUpdateProvider = groupsUpdateProvider;
-    this.sshKeyCache = sshKeyCache;
-    this.sshEnabled = sshEnabled;
   }
 
   public synchronized TestAccount create(
@@ -124,14 +107,7 @@
       }
     }
 
-    KeyPair sshKey = null;
-    if (sshEnabled && username != null) {
-      sshKey = genSshKey();
-      authorizedKeys.addKey(id, publicKey(sshKey, email));
-      sshKeyCache.evict(username);
-    }
-
-    account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
+    account = new TestAccount(id, username, email, fullName, httpPass);
     if (username != null) {
       accounts.put(username, account);
     }
@@ -174,18 +150,6 @@
     accounts.values().removeIf(a -> ids.contains(a.id));
   }
 
-  public static KeyPair genSshKey() throws JSchException {
-    JSch jsch = new JSch();
-    return KeyPair.genKeyPair(jsch, KeyPair.RSA);
-  }
-
-  public static String publicKey(KeyPair sshKey, String comment)
-      throws UnsupportedEncodingException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePublicKey(out, comment);
-    return out.toString(US_ASCII.name()).trim();
-  }
-
   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 d89dc92..acd5130a 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
+        "//java/com/google/gerrit/git/testing",
         "//java/com/google/gerrit/gpg/testing:gpg-test-util",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
@@ -72,12 +73,14 @@
         "//java/com/google/gerrit/pgm:daemon",
         "//java/com/google/gerrit/pgm/http/jetty",
         "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jimfs",
         "//lib:truth",
         "//lib:truth-java8-extension",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/httpcomponents:fluent-hc",
         "//lib/httpcomponents:httpclient",
         "//lib/httpcomponents:httpcore",
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index f561f32..5699e3f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -22,6 +22,8 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 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.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -430,6 +432,7 @@
           protected void configure() {
             bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
+            bind(AccountOperations.class).to(AccountOperationsImpl.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index e11651f..cdfdae7 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -19,10 +19,12 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.reviewdb.client.Project;
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.KeyPair;
 import com.jcraft.jsch.Session;
 import java.io.IOException;
 import java.util.List;
@@ -56,7 +58,7 @@
   private static final AtomicInteger testRepoCount = new AtomicInteger();
   private static final int TEST_REPO_WINDOW_DAYS = 2;
 
-  public static void initSsh(TestAccount a) {
+  public static void initSsh(KeyPair keyPair) {
     final Properties config = new Properties();
     config.put("StrictHostKeyChecking", "no");
     JSch.setConfig(config);
@@ -70,7 +72,8 @@
           protected void configure(Host hc, Session session) {
             try {
               final JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity("KeyPair", a.privateKey(), a.sshKey.getPublicKeyBlob(), null);
+              jsch.addIdentity(
+                  "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
             } catch (JSchException e) {
               throw new RuntimeException(e);
             }
diff --git a/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java b/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
index 62cc8ce..7e50b83 100644
--- a/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
@@ -33,7 +33,7 @@
   protected TestServerPlugin plugin;
 
   @Before
-  public void setUp() throws Exception {
+  public void setUpTestPlugin() throws Exception {
     TestPlugin testPlugin = getTestPlugin(getClass());
     String name = testPlugin.name();
     plugin =
@@ -52,7 +52,7 @@
   }
 
   @After
-  public void tearDown() {
+  public void tearDownTestPlugin() {
     if (plugin != null) {
       // plugin will be null if the plugin test requires ssh, but the command
       // line flag says we are running tests without ssh as the assume()
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index 86718be..7d17285 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -24,13 +24,17 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 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.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+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;
 import com.google.gerrit.server.project.RefPatternMatcher;
 import com.google.inject.Inject;
@@ -88,6 +92,9 @@
     @Nullable private final AccountCreator accountCreator;
     @Nullable private final AccountCache accountCache;
     @Nullable private final AccountIndexer accountIndexer;
+    @Nullable private final GroupCache groupCache;
+    @Nullable private final GroupIncludeCache groupIncludeCache;
+    @Nullable private final GroupIndexer groupIndexer;
     @Nullable private final ProjectCache projectCache;
 
     @Inject
@@ -97,12 +104,18 @@
         @Nullable AccountCreator accountCreator,
         @Nullable AccountCache accountCache,
         @Nullable AccountIndexer accountIndexer,
+        @Nullable GroupCache groupCache,
+        @Nullable GroupIncludeCache groupIncludeCache,
+        @Nullable GroupIndexer groupIndexer,
         @Nullable ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
       this.accountCreator = accountCreator;
       this.accountCache = accountCache;
       this.accountIndexer = accountIndexer;
+      this.groupCache = groupCache;
+      this.groupIncludeCache = groupIncludeCache;
+      this.groupIndexer = groupIndexer;
       this.projectCache = projectCache;
     }
 
@@ -113,6 +126,9 @@
           accountCreator,
           accountCache,
           accountIndexer,
+          groupCache,
+          groupIncludeCache,
+          groupIndexer,
           projectCache,
           input.refsByProject);
     }
@@ -139,12 +155,18 @@
   @Inject private AllUsersName allUsersName;
   @Inject @Nullable private AccountCreator accountCreator;
   @Inject @Nullable private AccountCache accountCache;
+  @Inject @Nullable private GroupCache groupCache;
+  @Inject @Nullable private GroupIncludeCache groupIncludeCache;
+  @Inject @Nullable private GroupIndexer groupIndexer;
   @Inject @Nullable private AccountIndexer accountIndexer;
   @Inject @Nullable private ProjectCache projectCache;
 
   private final Multimap<Project.NameKey, String> refsPatternByProject;
+
+  // State to which to reset to.
   private final Multimap<Project.NameKey, RefState> savedRefStatesByProject;
 
+  // Results of the resetting
   private Multimap<Project.NameKey, String> keptRefsByProject;
   private Multimap<Project.NameKey, String> restoredRefsByProject;
   private Multimap<Project.NameKey, String> deletedRefsByProject;
@@ -155,6 +177,9 @@
       @Nullable AccountCreator accountCreator,
       @Nullable AccountCache accountCache,
       @Nullable AccountIndexer accountIndexer,
+      @Nullable GroupCache groupCache,
+      @Nullable GroupIncludeCache groupIncludeCache,
+      @Nullable GroupIndexer groupIndexer,
       @Nullable ProjectCache projectCache,
       Multimap<Project.NameKey, String> refPatternByProject)
       throws IOException {
@@ -163,6 +188,9 @@
     this.accountCreator = accountCreator;
     this.accountCache = accountCache;
     this.accountIndexer = accountIndexer;
+    this.groupCache = groupCache;
+    this.groupIndexer = groupIndexer;
+    this.groupIncludeCache = groupIncludeCache;
     this.projectCache = projectCache;
     this.refsPatternByProject = refPatternByProject;
     this.savedRefStatesByProject = readRefStates();
@@ -265,8 +293,8 @@
   private void evictCachesAndReindex() throws IOException {
     evictAndReindexProjects();
     evictAndReindexAccounts();
+    evictAndReindexGroups();
 
-    // TODO(ekempin): Evict groups from cache if group refs were modified.
     // TODO(ekempin): Reindex changes if starred-changes refs in All-Users were modified.
   }
 
@@ -329,16 +357,47 @@
     }
   }
 
+  /** Evict groups that were modified. */
+  private void evictAndReindexGroups() throws IOException {
+    if (groupCache != null || groupIndexer != null) {
+      Set<AccountGroup.UUID> modifiedGroups =
+          new HashSet<>(groupUUIDs(restoredRefsByProject.get(allUsersName)));
+      Set<AccountGroup.UUID> deletedGroups =
+          new HashSet<>(groupUUIDs(deletedRefsByProject.get(allUsersName)));
+
+      // Evict and reindex all modified and deleted groups.
+      for (AccountGroup.UUID uuid : Sets.union(modifiedGroups, deletedGroups)) {
+        evictAndReindexGroup(uuid);
+      }
+    }
+  }
+
   private void evictAndReindexAccount(Account.Id accountId) throws IOException {
     if (accountCache != null) {
       accountCache.evict(accountId);
     }
-
+    if (groupIncludeCache != null) {
+      groupIncludeCache.evictGroupsWithMember(accountId);
+    }
     if (accountIndexer != null) {
       accountIndexer.index(accountId);
     }
   }
 
+  private void evictAndReindexGroup(AccountGroup.UUID uuid) throws IOException {
+    if (groupCache != null) {
+      groupCache.evict(uuid);
+    }
+
+    if (groupIncludeCache != null) {
+      groupIncludeCache.evictParentGroupsOf(uuid);
+    }
+
+    if (groupIndexer != null) {
+      groupIndexer.index(uuid);
+    }
+  }
+
   private Set<Account.Id> accountIds(Collection<String> refs) {
     return refs.stream()
         .filter(r -> r.startsWith(REFS_USERS))
@@ -346,4 +405,12 @@
         .filter(Objects::nonNull)
         .collect(toSet());
   }
+
+  private Set<AccountGroup.UUID> groupUUIDs(Collection<String> refs) {
+    return refs.stream()
+        .filter(RefNames::isRefsGroups)
+        .map(r -> AccountGroup.UUID.fromRef(r))
+        .filter(Objects::nonNull)
+        .collect(toSet());
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index f7369d7..9e515ca 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -16,32 +16,34 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.jcraft.jsch.ChannelExec;
 import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.KeyPair;
 import com.jcraft.jsch.Session;
-import java.io.IOException;
 import java.io.InputStream;
 import java.net.InetSocketAddress;
 import java.util.Scanner;
 
 public class SshSession {
+  private final TestSshKeys sshKeys;
   private final InetSocketAddress addr;
   private final TestAccount account;
   private Session session;
   private String error;
 
-  public SshSession(GerritServer server, TestAccount account) {
+  public SshSession(TestSshKeys sshKeys, GerritServer server, TestAccount account) {
+    this.sshKeys = sshKeys;
     this.addr = server.getSshdAddress();
     this.account = account;
   }
 
-  public void open() throws JSchException {
+  public void open() throws Exception {
     getSession();
   }
 
   @SuppressWarnings("resource")
-  public String exec(String command, InputStream opt) throws JSchException, IOException {
+  public String exec(String command, InputStream opt) throws Exception {
     ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
     try {
       channel.setCommand(command);
@@ -60,7 +62,7 @@
     }
   }
 
-  public InputStream exec2(String command, InputStream opt) throws JSchException, IOException {
+  public InputStream exec2(String command, InputStream opt) throws Exception {
     ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
     channel.setCommand(command);
     channel.setInputStream(opt);
@@ -69,7 +71,7 @@
     return in;
   }
 
-  public String exec(String command) throws JSchException, IOException {
+  public String exec(String command) throws Exception {
     return exec(command, null);
   }
 
@@ -88,10 +90,12 @@
     }
   }
 
-  private Session getSession() throws JSchException {
+  private Session getSession() throws Exception {
     if (session == null) {
+      KeyPair keyPair = sshKeys.getKeyPair(account);
       JSch jsch = new JSch();
-      jsch.addIdentity("KeyPair", account.privateKey(), account.sshKey.getPublicKeyBlob(), null);
+      jsch.addIdentity(
+          "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
       session =
           jsch.getSession(account.username, addr.getAddress().getHostAddress(), addr.getPort());
       session.setConfig("StrictHostKeyChecking", "no");
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index 3563ca1..094e8b0 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -19,8 +19,6 @@
 import com.google.common.net.InetAddresses;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.mail.Address;
-import com.jcraft.jsch.KeyPair;
-import java.io.ByteArrayOutputStream;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
 import java.util.List;
@@ -45,31 +43,17 @@
   public final String email;
   public final Address emailAddress;
   public final String fullName;
-  public final KeyPair sshKey;
   public final String httpPassword;
 
-  TestAccount(
-      Account.Id id,
-      String username,
-      String email,
-      String fullName,
-      KeyPair sshKey,
-      String httpPassword) {
+  TestAccount(Account.Id id, String username, String email, String fullName, String httpPassword) {
     this.id = id;
     this.username = username;
     this.email = email;
     this.emailAddress = new Address(fullName, email);
     this.fullName = fullName;
-    this.sshKey = sshKey;
     this.httpPassword = httpPassword;
   }
 
-  public byte[] privateKey() {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePrivateKey(out);
-    return out.toByteArray();
-  }
-
   public PersonIdent getIdent() {
     return new PersonIdent(fullName, email);
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java b/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java
new file mode 100644
index 0000000..d41672a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/ThrowingFunction.java
@@ -0,0 +1,21 @@
+// 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;
+
+@FunctionalInterface
+public interface ThrowingFunction<T, R> {
+
+  R apply(T value) throws Exception;
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
new file mode 100644
index 0000000..58a00d0
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.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.testsuite.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+
+/**
+ * An aggregation of operations on accounts 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 AccountOperations {
+
+  /**
+   * Starts the fluent chain for a querying or modifying an account. Please see the methods of
+   * {@link MoreAccountOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific account
+   */
+  MoreAccountOperations account(Account.Id accountId);
+
+  /**
+   * Starts the fluent chain to create an account. The returned builder can be used to specify the
+   * attributes of the new account. To create the account for real, {@link
+   * TestAccountCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * TestAccount createdAccount = accountOperations
+   *     .newAccount()
+   *     .username("janedoe")
+   *     .preferredEmail("janedoe@example.com")
+   *     .fullname("Jane Doe")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> If another account with the provided user name or preferred email
+   * address already exists, the creation of the account will fail.
+   *
+   * @return a builder to create the new account
+   */
+  TestAccountCreation.Builder newAccount();
+
+  /** An aggregation of methods on a specific account. */
+  interface MoreAccountOperations {
+
+    /**
+     * Checks whether the account exists.
+     *
+     * @return {@code true} if the account exists
+     */
+    boolean exists() throws Exception;
+
+    /**
+     * Retrieves the account.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested account
+     * doesn't exist. If you want to check for the existence of an account, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestAccount}
+     */
+    TestAccount get() throws Exception;
+
+    /**
+     * Starts the fluent chain to update an account. The returned builder can be used to specify how
+     * the attributes of the account should be modified. To update the account for real, {@link
+     * TestAccountUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * TestAccount updatedAccount = accountOperations.forUpdate().status("on vacation").update();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The update will fail with an exception if the account to update
+     * doesn't exist. If you want to check for the existence of an account, use {@link #exists()}.
+     *
+     * @return a builder to update the account
+     */
+    TestAccountUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
new file mode 100644
index 0000000..3d741b0
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -0,0 +1,167 @@
+// 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.account;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+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;
+
+/**
+ * The implementation of {@code AccountOperations}.
+ *
+ * <p>There is only one implementation of {@code AccountOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class AccountOperationsImpl implements AccountOperations {
+  private final Accounts accounts;
+  private final AccountsUpdate accountsUpdate;
+  private final Sequences seq;
+
+  @Inject
+  public AccountOperationsImpl(
+      Accounts accounts, @ServerInitiated AccountsUpdate accountsUpdate, Sequences seq) {
+    this.accounts = accounts;
+    this.accountsUpdate = accountsUpdate;
+    this.seq = seq;
+  }
+
+  @Override
+  public MoreAccountOperations account(Account.Id accountId) {
+    return new MoreAccountOperationsImpl(accountId);
+  }
+
+  @Override
+  public TestAccountCreation.Builder newAccount() {
+    return TestAccountCreation.builder(this::createAccount);
+  }
+
+  private TestAccount createAccount(TestAccountCreation accountCreation) throws Exception {
+    AccountsUpdate.AccountUpdater accountUpdater =
+        (account, updateBuilder) ->
+            fillBuilder(updateBuilder, accountCreation, account.getAccount().getId());
+    AccountState createdAccount = createAccount(accountUpdater);
+    return toTestAccount(createdAccount);
+  }
+
+  private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
+      throws OrmException, IOException, ConfigInvalidException {
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
+  }
+
+  private static void fillBuilder(
+      InternalAccountUpdate.Builder builder,
+      TestAccountCreation accountCreation,
+      Account.Id accountId) {
+    accountCreation.fullname().ifPresent(builder::setFullName);
+    accountCreation.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
+    String httpPassword = accountCreation.httpPassword().orElse(null);
+    accountCreation.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
+    accountCreation.status().ifPresent(builder::setStatus);
+    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
+        .setPreferredEmail(preferredEmail)
+        .addExternalId(ExternalId.createEmail(accountId, preferredEmail));
+  }
+
+  private static InternalAccountUpdate.Builder setUsername(
+      InternalAccountUpdate.Builder builder,
+      Account.Id accountId,
+      String username,
+      String httpPassword) {
+    return builder.addExternalId(ExternalId.createUsername(username, accountId, httpPassword));
+  }
+
+  private class MoreAccountOperationsImpl implements MoreAccountOperations {
+    private final Account.Id accountId;
+
+    MoreAccountOperationsImpl(Account.Id accountId) {
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean exists() throws Exception {
+      return accounts.get(accountId).isPresent();
+    }
+
+    @Override
+    public TestAccount get() throws Exception {
+      AccountState account =
+          accounts
+              .get(accountId)
+              .orElseThrow(
+                  () -> new IllegalStateException("Tried to get non-existing test account"));
+      return toTestAccount(account);
+    }
+
+    @Override
+    public TestAccountUpdate.Builder forUpdate() {
+      return TestAccountUpdate.builder(this::updateAccount);
+    }
+
+    private TestAccount 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)
+        throws OrmException, IOException, ConfigInvalidException {
+      return accountsUpdate.update("Update Test Account", accountId, accountUpdater);
+    }
+
+    private void fillBuilder(
+        InternalAccountUpdate.Builder builder,
+        TestAccountUpdate accountUpdate,
+        Account.Id accountId) {
+      accountUpdate.fullname().ifPresent(builder::setFullName);
+      accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
+      String httpPassword = accountUpdate.httpPassword().orElse(null);
+      accountUpdate.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
+      accountUpdate.status().ifPresent(builder::setStatus);
+      accountUpdate.active().ifPresent(builder::setActive);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
new file mode 100644
index 0000000..e7ffeec
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -0,0 +1,51 @@
+// 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.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Account;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestAccount {
+  public abstract Account.Id accountId();
+
+  public abstract Optional<String> fullname();
+
+  public abstract Optional<String> preferredEmail();
+
+  public abstract Optional<String> username();
+
+  public abstract boolean active();
+
+  static Builder builder() {
+    return new AutoValue_TestAccount.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder accountId(Account.Id accountId);
+
+    abstract Builder fullname(Optional<String> fullname);
+
+    abstract Builder preferredEmail(Optional<String> fullname);
+
+    abstract Builder username(Optional<String> username);
+
+    abstract Builder active(boolean active);
+
+    abstract TestAccount build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
new file mode 100644
index 0000000..a82d180
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -0,0 +1,95 @@
+// 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.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestAccountCreation {
+  public abstract Optional<String> fullname();
+
+  public abstract Optional<String> httpPassword();
+
+  public abstract Optional<String> preferredEmail();
+
+  public abstract Optional<String> username();
+
+  public abstract Optional<String> status();
+
+  public abstract Optional<Boolean> active();
+
+  abstract ThrowingFunction<TestAccountCreation, TestAccount> accountCreator();
+
+  public static Builder builder(ThrowingFunction<TestAccountCreation, TestAccount> accountCreator) {
+    return new AutoValue_TestAccountCreation.Builder()
+        .accountCreator(accountCreator)
+        .httpPassword("http-pass");
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder fullname(String fullname);
+
+    public Builder clearFullname() {
+      return fullname("");
+    }
+
+    public abstract Builder httpPassword(String httpPassword);
+
+    public Builder clearHttpPassword() {
+      return httpPassword("");
+    }
+
+    public abstract Builder preferredEmail(String preferredEmail);
+
+    public Builder clearPreferredEmail() {
+      return preferredEmail("");
+    }
+
+    public abstract Builder username(String username);
+
+    public Builder clearUsername() {
+      return username("");
+    }
+
+    public abstract Builder status(String status);
+
+    public Builder clearStatus() {
+      return status("");
+    }
+
+    abstract Builder active(boolean active);
+
+    public Builder active() {
+      return active(true);
+    }
+
+    public Builder inactive() {
+      return active(false);
+    }
+
+    abstract Builder accountCreator(
+        ThrowingFunction<TestAccountCreation, TestAccount> accountCreator);
+
+    abstract TestAccountCreation autoBuild();
+
+    public TestAccount 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
new file mode 100644
index 0000000..517e4b5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -0,0 +1,95 @@
+// 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.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestAccountUpdate {
+  public abstract Optional<String> fullname();
+
+  public abstract Optional<String> httpPassword();
+
+  public abstract Optional<String> preferredEmail();
+
+  public abstract Optional<String> username();
+
+  public abstract Optional<String> status();
+
+  public abstract Optional<Boolean> active();
+
+  abstract ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater();
+
+  public static Builder builder(ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater) {
+    return new AutoValue_TestAccountUpdate.Builder()
+        .accountUpdater(accountUpdater)
+        .httpPassword("http-pass");
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder fullname(String fullname);
+
+    public Builder clearFullname() {
+      return fullname("");
+    }
+
+    public abstract Builder httpPassword(String httpPassword);
+
+    public Builder clearHttpPassword() {
+      return httpPassword("");
+    }
+
+    public abstract Builder preferredEmail(String preferredEmail);
+
+    public Builder clearPreferredEmail() {
+      return preferredEmail("");
+    }
+
+    public abstract Builder username(String username);
+
+    public Builder clearUsername() {
+      return username("");
+    }
+
+    public abstract Builder status(String status);
+
+    public Builder clearStatus() {
+      return status("");
+    }
+
+    abstract Builder active(boolean active);
+
+    public Builder active() {
+      return active(true);
+    }
+
+    public Builder inactive() {
+      return active(false);
+    }
+
+    abstract Builder accountUpdater(
+        ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater);
+
+    abstract TestAccountUpdate autoBuild();
+
+    public TestAccount update() throws Exception {
+      TestAccountUpdate accountUpdate = autoBuild();
+      return accountUpdate.accountUpdater().apply(accountUpdate);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
new file mode 100644
index 0000000..0cb5cf3
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.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.account;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import com.google.gerrit.acceptance.SshEnabled;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.KeyPair;
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+@Singleton
+public class TestSshKeys {
+  private final Map<String, KeyPair> sshKeyPairs;
+
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+  private final boolean sshEnabled;
+
+  @Inject
+  TestSshKeys(
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      @SshEnabled boolean sshEnabled) {
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+    this.sshEnabled = sshEnabled;
+    this.sshKeyPairs = new HashMap<>();
+  }
+
+  // TODO(ekempin): Remove this method when com.google.gerrit.acceptance.TestAccount is gone
+  public KeyPair getKeyPair(com.google.gerrit.acceptance.TestAccount account) throws Exception {
+    checkState(sshEnabled, "Requested SSH key pair, but SSH is disabled");
+    checkState(
+        account.username != null,
+        "Requested SSH key pair for account %s, but username is not set",
+        account.id);
+
+    String username = account.username;
+    KeyPair keyPair = sshKeyPairs.get(username);
+    if (keyPair == null) {
+      keyPair = createKeyPair(account.id, username, account.email);
+      sshKeyPairs.put(username, keyPair);
+    }
+    return keyPair;
+  }
+
+  public KeyPair getKeyPair(TestAccount account) throws Exception {
+    checkState(sshEnabled, "Requested SSH key pair, but SSH is disabled");
+    checkState(
+        account.username().isPresent(),
+        "Requested SSH key pair for account %s, but username is not set",
+        account.accountId());
+
+    String username = account.username().get();
+    KeyPair keyPair = sshKeyPairs.get(username);
+    if (keyPair == null) {
+      keyPair = createKeyPair(account.accountId(), username, account.preferredEmail().orElse(null));
+      sshKeyPairs.put(username, keyPair);
+    }
+    return keyPair;
+  }
+
+  private KeyPair createKeyPair(Account.Id accountId, String username, @Nullable String email)
+      throws Exception {
+    KeyPair keyPair = genSshKey();
+    authorizedKeys.addKey(accountId, publicKey(keyPair, email));
+    sshKeyCache.evict(username);
+    return keyPair;
+  }
+
+  public static KeyPair genSshKey() throws JSchException {
+    JSch jsch = new JSch();
+    return KeyPair.genKeyPair(jsch, KeyPair.ECDSA, 256);
+  }
+
+  public static String publicKey(KeyPair sshKey, @Nullable String comment)
+      throws UnsupportedEncodingException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    sshKey.writePublicKey(out, comment);
+    return out.toString(US_ASCII.name()).trim();
+  }
+
+  public static byte[] privateKey(KeyPair keyPair) {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    keyPair.writePrivateKey(out);
+    return out.toByteArray();
+  }
+}
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 919f532..2565f0d 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -22,6 +22,8 @@
         "//lib:guava",
         "//lib:gwtorm_client",
         "//lib:servlet-api-3_1",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
     ],
@@ -46,6 +48,8 @@
         "//lib:gwtjsonrpc",
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
     ],
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 2f1e199..95f48b1 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -75,8 +75,15 @@
     return null;
   }
 
-  public void addPermission(Permission p) {
-    getPermissions().add(p);
+  public void addPermission(Permission permission) {
+    List<Permission> permissions = getPermissions();
+    for (Permission p : permissions) {
+      if (p.getName().equalsIgnoreCase(permission.getName())) {
+        throw new IllegalArgumentException();
+      }
+    }
+
+    permissions.add(permission);
   }
 
   public void remove(Permission permission) {
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index c15f7b9..e613d21 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -114,6 +114,9 @@
   /** Can view all pending tasks in the queue (not just the filtered set). */
   public static final String VIEW_QUEUE = "viewQueue";
 
+  /** Can query permissions for any (project, user) pair */
+  public static final String VIEW_ACCESS = "viewAccess";
+
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
   private static final String[] RANGE_NAMES = {
@@ -143,6 +146,7 @@
     NAMES_ALL.add(VIEW_CONNECTIONS);
     NAMES_ALL.add(VIEW_PLUGINS);
     NAMES_ALL.add(VIEW_QUEUE);
+    NAMES_ALL.add(VIEW_ACCESS);
 
     NAMES_LC = new ArrayList<>(NAMES_ALL.size());
     for (String name : NAMES_ALL) {
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index b82b931..4ee176b 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -144,14 +144,14 @@
     return extractLabel(getName());
   }
 
-  public Boolean getExclusiveGroup() {
+  public boolean getExclusiveGroup() {
     // Only permit exclusive group behavior on non OWNER permissions,
     // otherwise an owner might lose access to a delegated subspace.
     //
     return exclusiveGroup && !OWNER.equals(getName());
   }
 
-  public void setExclusiveGroup(Boolean newExclusiveGroup) {
+  public void setExclusiveGroup(boolean newExclusiveGroup) {
     exclusiveGroup = newExclusiveGroup;
   }
 
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
index 3cd9c43..ae8b2bb 100644
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.common.annotations.GwtIncompatible;
 import com.google.gerrit.reviewdb.client.Account;
 import java.util.Collection;
 import java.util.List;
@@ -60,7 +61,7 @@
 
   public Status status;
   public List<Label> labels;
-  public List<SubmitRequirement> requirements;
+  @GwtIncompatible public List<SubmitRequirement> requirements;
   public String errorMessage;
 
   public static class Label {
@@ -131,6 +132,7 @@
     }
   }
 
+  @GwtIncompatible
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
@@ -158,6 +160,7 @@
     return sb.toString();
   }
 
+  @GwtIncompatible
   @Override
   public boolean equals(Object o) {
     if (o instanceof SubmitRecord) {
@@ -170,6 +173,7 @@
     return false;
   }
 
+  @GwtIncompatible
   @Override
   public int hashCode() {
     return Objects.hash(status, labels, errorMessage, requirements);
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
index a75f3c0..0a8d5ac0 100644
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ b/java/com/google/gerrit/common/data/SubmitRequirement.java
@@ -14,67 +14,65 @@
 
 package com.google.gerrit.common.data;
 
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.common.Nullable;
-import java.util.Objects;
-import java.util.Optional;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.GwtIncompatible;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
 
 /** Describes a requirement to submit a change. */
-public final class SubmitRequirement {
-  private final String shortReason;
-  private final String fullReason;
-  @Nullable private final String label;
+@GwtIncompatible
+@AutoValue
+@AutoValue.CopyAnnotations
+public abstract class SubmitRequirement {
+  private static final CharMatcher TYPE_MATCHER =
+      CharMatcher.inRange('a', 'z')
+          .or(CharMatcher.inRange('A', 'Z'))
+          .or(CharMatcher.inRange('0', '9'))
+          .or(CharMatcher.anyOf("-_"));
 
-  public SubmitRequirement(String shortReason, String fullReason, @Nullable String label) {
-    this.shortReason = requireNonNull(shortReason);
-    this.fullReason = requireNonNull(fullReason);
-    this.label = label;
-  }
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setType(String value);
 
-  public String shortReason() {
-    return shortReason;
-  }
+    public abstract Builder setFallbackText(String value);
 
-  public String fullReason() {
-    return fullReason;
-  }
-
-  public Optional<String> label() {
-    return Optional.ofNullable(label);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (this == o) {
-      return true;
+    public Builder setData(Map<String, String> value) {
+      return setData(ImmutableMap.copyOf(value));
     }
-    if (o instanceof SubmitRequirement) {
-      SubmitRequirement that = (SubmitRequirement) o;
-      return Objects.equals(shortReason, that.shortReason)
-          && Objects.equals(fullReason, that.fullReason)
-          && Objects.equals(label, that.label);
+
+    public Builder addCustomValue(String key, String value) {
+      dataBuilder().put(key, value);
+      return this;
     }
-    return false;
+
+    public SubmitRequirement build() {
+      SubmitRequirement requirement = autoBuild();
+      Preconditions.checkState(
+          validateType(requirement.type()),
+          "SubmitRequirement's type contains non alphanumerical symbols.");
+      return requirement;
+    }
+
+    abstract Builder setData(ImmutableMap<String, String> value);
+
+    abstract ImmutableMap.Builder<String, String> dataBuilder();
+
+    abstract SubmitRequirement autoBuild();
   }
 
-  @Override
-  public int hashCode() {
-    return Objects.hash(shortReason, fullReason, label);
+  public abstract String fallbackText();
+
+  public abstract String type();
+
+  public abstract ImmutableMap<String, String> data();
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirement.Builder();
   }
 
-  @Override
-  public String toString() {
-    return "SubmitRequirement{"
-        + "shortReason='"
-        + shortReason
-        + '\''
-        + ", fullReason='"
-        + fullReason
-        + '\''
-        + ", label='"
-        + label
-        + '\''
-        + '}';
+  private static boolean validateType(String type) {
+    return TYPE_MATCHER.matchesAllOf(type);
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 3ad31ec..d4d33c7 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -189,21 +189,24 @@
   }
 
   private String toDocument(V v) throws IOException {
-    XContentBuilder builder = jsonBuilder().startObject();
-    for (Values<V> values : schema.buildFields(v)) {
-      String name = values.getField().getName();
-      if (values.getField().isRepeatable()) {
-        builder.field(
-            name,
-            Streams.stream(values.getValues()).filter(e -> shouldAddElement(e)).collect(toList()));
-      } else {
-        Object element = Iterables.getOnlyElement(values.getValues(), "");
-        if (shouldAddElement(element)) {
-          builder.field(name, element);
+    try (XContentBuilder builder = jsonBuilder().startObject()) {
+      for (Values<V> values : schema.buildFields(v)) {
+        String name = values.getField().getName();
+        if (values.getField().isRepeatable()) {
+          builder.field(
+              name,
+              Streams.stream(values.getValues())
+                  .filter(e -> shouldAddElement(e))
+                  .collect(toList()));
+        } else {
+          Object element = Iterables.getOnlyElement(values.getValues(), "");
+          if (shouldAddElement(element)) {
+            builder.field(name, element);
+          }
         }
       }
+      return builder.endObject().string();
     }
-    return builder.endObject().string();
   }
 
   protected abstract V fromDocument(JsonObject doc, Set<String> fields);
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index 0aa7a39..f5ada85 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -17,11 +17,11 @@
         "//lib/commons:codec",
         "//lib/commons:lang",
         "//lib/elasticsearch",
-        "//lib/elasticsearch:jest",
-        "//lib/elasticsearch:jest-common",
         "//lib/elasticsearch:joda-time",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//lib/jest",
+        "//lib/jest:jest-common",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
         "//lib/lucene:lucene-analyzers-common",
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index fe2554d..0a06c31 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -69,6 +69,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.lib.Config;
@@ -221,9 +222,24 @@
       // Changed lines.
       int added = addedElement.getAsInt();
       int deleted = deletedElement.getAsInt();
-      if (added != 0 && deleted != 0) {
-        cd.setChangedLines(added, deleted);
+      cd.setChangedLines(added, deleted);
+    }
+
+    // Star.
+    JsonElement starredElement = source.get(ChangeField.STAR.getName());
+    if (starredElement != null) {
+      ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
+      JsonArray starBy = starredElement.getAsJsonArray();
+      if (starBy.size() > 0) {
+        for (int i = 0; i < starBy.size(); i++) {
+          String[] indexableFields = starBy.get(i).getAsString().split(":");
+          Optional<Account.Id> id = Account.Id.tryParse(indexableFields[0]);
+          if (id.isPresent()) {
+            stars.put(id.get(), indexableFields[1]);
+          }
+        }
       }
+      cd.setStars(stars);
     }
 
     // Mergeable.
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 5a9c6ac..76fdfea 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -14,28 +14,15 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
-import com.google.gerrit.server.index.SingleVersionModule;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
 import java.util.Map;
-import org.eclipse.jgit.lib.Config;
 
-public class ElasticIndexModule extends AbstractModule {
+public class ElasticIndexModule extends AbstractIndexModule {
   public static ElasticIndexModule singleVersionWithExplicitVersions(
       Map<String, Integer> versions, int threads, boolean slave) {
     return new ElasticIndexModule(versions, threads, false, slave);
@@ -49,74 +36,33 @@
     return new ElasticIndexModule(null, 0, false, slave);
   }
 
-  private final Map<String, Integer> singleVersions;
-  private final int threads;
-  private final boolean onlineUpgrade;
-  private final boolean slave;
-
   private ElasticIndexModule(
       Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
-    if (singleVersions != null) {
-      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
-    }
-    this.singleVersions = singleVersions;
-    this.threads = threads;
-    this.onlineUpgrade = onlineUpgrade;
-    this.slave = slave;
+    super(singleVersions, threads, onlineUpgrade, slave);
   }
 
   @Override
-  protected void configure() {
-    if (slave) {
-      bind(AccountIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
-      bind(ChangeIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
-      bind(ProjectIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
-    } else {
-      install(
-          new FactoryModuleBuilder()
-              .implement(AccountIndex.class, ElasticAccountIndex.class)
-              .build(AccountIndex.Factory.class));
-      install(
-          new FactoryModuleBuilder()
-              .implement(ChangeIndex.class, ElasticChangeIndex.class)
-              .build(ChangeIndex.Factory.class));
-      install(
-          new FactoryModuleBuilder()
-              .implement(ProjectIndex.class, ElasticProjectIndex.class)
-              .build(ProjectIndex.Factory.class));
-    }
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, ElasticGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModule(threads, slave));
-    if (singleVersions == null) {
-      install(new MultiVersionModule());
-    } else {
-      install(new SingleVersionModule(singleVersions));
-    }
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return ElasticAccountIndex.class;
   }
 
-  @SuppressWarnings("unused")
-  private static <T> T createDummyIndexFactory(Schema<?> schema) {
-    throw new UnsupportedOperationException();
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return ElasticChangeIndex.class;
   }
 
-  @Provides
-  @Singleton
-  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return ElasticGroupIndex.class;
   }
 
-  private class MultiVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      bind(VersionManager.class).to(ElasticVersionManager.class);
-      listener().to(ElasticVersionManager.class);
-      if (onlineUpgrade) {
-        listener().to(OnlineUpgrader.class);
-      }
-    }
+  @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return ElasticProjectIndex.class;
+  }
+
+  @Override
+  protected Class<? extends VersionManager> getVersionManager() {
+    return ElasticVersionManager.class;
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
new file mode 100644
index 0000000..133de31
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.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.extensions.api.access;
+
+import java.util.Locale;
+
+/** Gerrit permission for hosts, projects, refs, changes, labels and plugins. */
+public interface GerritPermission {
+  /** @return readable identifier of this permission for exception message. */
+  String describeForException();
+
+  static String describeEnumValue(Enum<?> value) {
+    return value.name().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java b/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
index deae084..95b887d 100644
--- a/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
@@ -17,10 +17,4 @@
 /**
  * A {@link com.google.gerrit.server.permissions.GlobalPermission} or a {@link PluginPermission}.
  */
-public interface GlobalOrPluginPermission {
-  /** @return name used in {@code project.config} permissions. */
-  public String permissionName();
-
-  /** @return readable identifier of this permission for exception message. */
-  public String describeForException();
-}
+public interface GlobalOrPluginPermission extends GerritPermission {}
diff --git a/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
index 7a467b8..449135d 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -47,11 +47,6 @@
   }
 
   @Override
-  public String permissionName() {
-    return pluginName + '-' + capability;
-  }
-
-  @Override
   public String describeForException() {
     return capability + " for plugin " + pluginName;
   }
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 912ad64..0c3a11f 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -78,6 +78,10 @@
 
   void deleteEmail(String email) throws RestApiException;
 
+  EmailApi createEmail(EmailInput emailInput) throws RestApiException;
+
+  EmailApi email(String email) throws RestApiException;
+
   void setStatus(String status) throws RestApiException;
 
   List<SshKeyInfo> listSshKeys() throws RestApiException;
@@ -220,6 +224,16 @@
     }
 
     @Override
+    public EmailApi createEmail(EmailInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public EmailApi email(String email) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void setStatus(String status) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/accounts/EmailApi.java b/java/com/google/gerrit/extensions/api/accounts/EmailApi.java
new file mode 100644
index 0000000..da038c3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/EmailApi.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.extensions.api.accounts;
+
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface EmailApi {
+  EmailInfo get() throws RestApiException;
+
+  void delete() throws RestApiException;
+
+  void setPreferred() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements EmailApi {
+    @Override
+    public EmailInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setPreferred() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index a13fb75..b140064 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -36,15 +36,6 @@
   public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
-   * If true require all labels to be within the user's permitted ranges based on access controls,
-   * attempting to use a label not granted to the user will fail the entire modify operation early.
-   * If false the operation will execute anyway, but the proposed labels given by the user will be
-   * modified to be the "best" value allowed by the access controls, or ignored if the label does
-   * not exist.
-   */
-  public boolean strictLabels = true;
-
-  /**
    * How to process draft comments already in the database that were not also described in this
    * input request.
    *
@@ -66,8 +57,6 @@
    * on behalf of this named user instead of the caller. Caller must have the labelAs-$NAME
    * permission granted for each label that appears in {@link #labels}. This is in addition to the
    * named user also needing to have permission to use the labels.
-   *
-   * <p>{@link #strictLabels} impacts how labels is processed for the named user, not the caller.
    */
   public String onBehalfOf;
 
diff --git a/java/com/google/gerrit/extensions/api/config/ConfigUpdateEntryInfo.java b/java/com/google/gerrit/extensions/api/config/ConfigUpdateEntryInfo.java
new file mode 100644
index 0000000..4ebf7b2
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/config/ConfigUpdateEntryInfo.java
@@ -0,0 +1,21 @@
+// 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.config;
+
+public class ConfigUpdateEntryInfo {
+  public String configKey;
+  public String oldValue;
+  public String newValue;
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index f802049..c95dcc3 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -70,4 +70,5 @@
   public List<ProblemInfo> problems;
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
+  public Collection<SubmitRequirementInfo> requirements;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..a940403
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.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.extensions.common;
+
+import java.util.Map;
+import java.util.Objects;
+
+public class SubmitRequirementInfo {
+  public final String status;
+  public final String fallbackText;
+  public final String type;
+  public final Map<String, String> data;
+
+  public SubmitRequirementInfo(
+      String status, String fallbackText, String type, Map<String, String> data) {
+    this.status = status;
+    this.fallbackText = fallbackText;
+    this.type = type;
+    this.data = data;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementInfo)) {
+      return false;
+    }
+    SubmitRequirementInfo that = (SubmitRequirementInfo) o;
+    return Objects.equals(status, that.status)
+        && Objects.equals(fallbackText, that.fallbackText)
+        && Objects.equals(type, that.type)
+        && Objects.equals(data, that.data);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, fallbackText, type, data);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 48f7f45..9030a1c 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.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.common.truth.Truth;
@@ -68,4 +69,16 @@
     ContentEntry contentEntry = actual();
     return ListSubject.assertThat(contentEntry.b, Truth::assertThat).named("lines of 'b'");
   }
+
+  public IterableSubject intralineEditsOfA() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.editA).named("intraline edits of 'a'");
+  }
+
+  public IterableSubject intralineEditsOfB() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java b/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
index 1b95778d..d368ed4 100644
--- a/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
+++ b/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
@@ -23,8 +23,8 @@
 
   /**
    * Returns additional entries for IncludedInInfo as multimap where the key is the row title and
-   * the the values are a list of systems that include the given commit (e.g. names of servers on
-   * which this commit is deployed).
+   * the values are a list of systems that include the given commit (e.g. names of servers on which
+   * this commit is deployed).
    *
    * <p>The tags and branches in which the commit is included are provided so that a RevWalk can be
    * avoided when a system runs a certain tag or branch.
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
new file mode 100644
index 0000000..0b83560
--- /dev/null
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -0,0 +1,14 @@
+package(default_testonly = 1)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib:truth",
+        "//lib:truth-java8-extension",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
new file mode 100644
index 0000000..c5163d1
--- /dev/null
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -0,0 +1,172 @@
+// 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.git.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertAbout;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.common.truth.Truth8;
+import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+
+public class PushResultSubject extends Subject<PushResultSubject, PushResult> {
+  public static PushResultSubject assertThat(PushResult actual) {
+    return assertAbout(PushResultSubject::new).that(actual);
+  }
+
+  private PushResultSubject(FailureMetadata metadata, PushResult actual) {
+    super(metadata, actual);
+  }
+
+  public void hasNoMessages() {
+    Truth.assertWithMessage("expected no messages")
+        .that(Strings.nullToEmpty(trimMessages()))
+        .isEqualTo("");
+  }
+
+  public void hasMessages(String... expectedLines) {
+    checkArgument(expectedLines.length > 0, "use hasNoMessages()");
+    isNotNull();
+    Truth.assertThat(trimMessages()).isEqualTo(Arrays.stream(expectedLines).collect(joining("\n")));
+  }
+
+  private String trimMessages() {
+    return trimMessages(actual().getMessages());
+  }
+
+  @VisibleForTesting
+  @Nullable
+  static String trimMessages(@Nullable String msg) {
+    if (msg == null) {
+      return null;
+    }
+    int idx = msg.indexOf("Processing changes:");
+    if (idx >= 0) {
+      msg = msg.substring(0, idx);
+    }
+    return msg.trim();
+  }
+
+  public void hasProcessed(ImmutableMap<String, Integer> expected) {
+    ImmutableMap<String, Integer> actual;
+    String messages = actual().getMessages();
+    try {
+      actual = parseProcessed(messages);
+    } catch (RuntimeException e) {
+      Truth.assert_()
+          .fail(
+              "failed to parse \"Processing changes\" line from messages: %s\n%s",
+              messages, Throwables.getStackTraceAsString(e));
+      return;
+    }
+    Truth.assertThat(actual)
+        .named("processed commands")
+        .containsExactlyEntriesIn(expected)
+        .inOrder();
+  }
+
+  @VisibleForTesting
+  static ImmutableMap<String, Integer> parseProcessed(@Nullable String messages) {
+    if (messages == null) {
+      return ImmutableMap.of();
+    }
+    String toSplit = messages.trim();
+    String prefix = "Processing changes: ";
+    int idx = toSplit.lastIndexOf(prefix);
+    if (idx < 0) {
+      return ImmutableMap.of();
+    }
+    toSplit = toSplit.substring(idx + prefix.length());
+    if (toSplit.equals("done")) {
+      return ImmutableMap.of();
+    }
+    String done = ", done";
+    if (toSplit.endsWith(done)) {
+      toSplit = toSplit.substring(0, toSplit.length() - done.length());
+    }
+    return ImmutableMap.copyOf(
+        Maps.transformValues(
+            Splitter.on(',').trimResults().withKeyValueSeparator(':').split(toSplit),
+            // trimResults() doesn't trim values in the map.
+            v -> Integer.parseInt(v.trim())));
+  }
+
+  public RemoteRefUpdateSubject ref(String refName) {
+    return assertAbout(
+            (FailureMetadata m, RemoteRefUpdate a) -> new RemoteRefUpdateSubject(refName, m, a))
+        .that(actual().getRemoteUpdate(refName));
+  }
+
+  public RemoteRefUpdateSubject onlyRef(String refName) {
+    Truth8.assertThat(actual().getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
+        .named("set of refs")
+        .containsExactly(refName);
+    return ref(refName);
+  }
+
+  public static class RemoteRefUpdateSubject
+      extends Subject<RemoteRefUpdateSubject, RemoteRefUpdate> {
+    private final String refName;
+
+    private RemoteRefUpdateSubject(
+        String refName, FailureMetadata metadata, RemoteRefUpdate actual) {
+      super(metadata, actual);
+      this.refName = refName;
+      named("ref update for %s", refName).isNotNull();
+    }
+
+    public void hasStatus(RemoteRefUpdate.Status status) {
+      RemoteRefUpdate u = actual();
+      Truth.assertThat(u.getStatus())
+          .named(
+              "status of ref update for %s%s",
+              refName, u.getMessage() != null ? ": " + u.getMessage() : "")
+          .isEqualTo(status);
+    }
+
+    public void hasNoMessage() {
+      Truth.assertThat(actual().getMessage())
+          .named("message of ref update for %s", refName)
+          .isNull();
+    }
+
+    public void hasMessage(String expected) {
+      Truth.assertThat(actual().getMessage())
+          .named("message of ref update for %s", refName)
+          .isEqualTo(expected);
+    }
+
+    public void isOk() {
+      hasStatus(RemoteRefUpdate.Status.OK);
+    }
+
+    public void isRejected(String expectedMessage) {
+      hasStatus(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+      hasMessage(expectedMessage);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 7b97561..a636a8b 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -20,7 +20,9 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.Input;
 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.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -32,6 +34,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.util.Optional;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -60,19 +63,20 @@
 
   @Override
   public Response<?> apply(GpgKey rsrc, Input input)
-      throws ResourceConflictException, PGPException, OrmException, IOException,
-          ConfigInvalidException {
+      throws RestApiException, PGPException, OrmException, IOException, ConfigInvalidException {
     PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
-    ExternalId extId =
-        externalIds.get(
-            ExternalId.Key.create(
-                SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
+    String fingerprint = BaseEncoding.base16().encode(key.getFingerprint());
+    Optional<ExternalId> extId = externalIds.get(ExternalId.Key.create(SCHEME_GPGKEY, fingerprint));
+    if (!extId.isPresent()) {
+      throw new ResourceNotFoundException(fingerprint);
+    }
+
     accountsUpdateProvider
         .get()
         .update(
             "Delete GPG Key via API",
             rsrc.getUser().getAccountId(),
-            u -> u.deleteExternalId(extId));
+            u -> u.deleteExternalId(extId.get()));
 
     try (PublicKeyStore store = storeProvider.get()) {
       store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index e6918f70..b8b0bc8 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -106,7 +106,7 @@
             throws IOException, ServletException {
           while (itr.hasNext()) {
             AllRequestFilter filter = itr.next();
-            // To avoid {@code synchronized} on the the whole filtering (and
+            // To avoid {@code synchronized} on the whole filtering (and
             // thereby killing concurrency), we start the below disjunction
             // with an unsynchronized check for containment. This
             // unsynchronized check is always correct if no filters got
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index d7cbdb8..b62c887 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -30,6 +30,7 @@
         "//lib:servlet-api-3_1",
         "//lib:soy",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 9624241..f240088 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -19,6 +19,7 @@
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
         "//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/restapi",
         "//java/com/google/gerrit/server/schema",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 7aa5c63..6cbb357 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -41,11 +41,13 @@
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
 import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
 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.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -57,13 +59,13 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
@@ -328,7 +330,7 @@
     modules.add(new JdbcAccountPatchReviewStore.Module(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
     modules.add(new StreamEventsApiListener.Module());
-    modules.add(new ReceiveCommitsExecutorModule());
+    modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
@@ -336,7 +338,8 @@
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
-    modules.add(new DefaultCacheFactory.Module());
+    modules.add(new DefaultMemoryCacheModule());
+    modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
@@ -384,9 +387,9 @@
     modules.add(new GarbageCollectionModule());
     modules.add(new ChangeCleanupRunner.Module());
     modules.add(new AccountDeactivator.Module());
-    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
     modules.add(new DefaultProjectNameLockManager.Module());
-    return cfgInjector.createChildInjector(modules);
+    return cfgInjector.createChildInjector(
+        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
 
   private Module createIndexModule() {
@@ -413,9 +416,7 @@
             false,
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
-    }
+    modules.add(new IndexCommandsModule(sysInjector));
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 2cdca13..915e9ed 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -76,8 +76,8 @@
           "/", "/c/*", "/p/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
   // TODO(dborowitz): These fragments conflict with the REST API
   // namespace, so they will need to use a different path.
-  //"/groups/*",
-  //"/projects/*");
+  // "/groups/*",
+  // "/projects/*");
   //
 
   /**
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 8352f57..dc2639b 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -102,6 +102,7 @@
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+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.permissions.GlobalPermission;
@@ -278,7 +279,7 @@
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
-    try {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
       if (isCorsPreflight(req)) {
         doCorsPreflight(req, res);
         return;
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 015eceb..f293b2d 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -42,6 +42,7 @@
         "//lib:gwtorm",
         "//lib/antlr:java_runtime",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
     ],
diff --git a/java/com/google/gerrit/index/query/PostFilterPredicate.java b/java/com/google/gerrit/index/query/PostFilterPredicate.java
index 3e780bf..78b4c2b 100644
--- a/java/com/google/gerrit/index/query/PostFilterPredicate.java
+++ b/java/com/google/gerrit/index/query/PostFilterPredicate.java
@@ -18,4 +18,8 @@
  * Matches all documents in the index, with additional filtering done in the subclass's {@code
  * match} method.
  */
-public abstract class PostFilterPredicate<T> extends Predicate<T> implements Matchable<T> {}
+public abstract class PostFilterPredicate<T> extends OperatorPredicate<T> implements Matchable<T> {
+  public PostFilterPredicate(String operator, String value) {
+    super(operator, value);
+  }
+}
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index b7d232d..13dad0e 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -305,9 +305,9 @@
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
-      parent = new URLClassLoader(extapi.toArray(new URL[extapi.size()]), parent);
+      parent = URLClassLoader.newInstance(extapi.toArray(new URL[extapi.size()]), parent);
     }
-    return new URLClassLoader(jars.values().toArray(new URL[jars.size()]), parent);
+    return URLClassLoader.newInstance(jars.values().toArray(new URL[jars.size()]), parent);
   }
 
   private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
@@ -718,7 +718,7 @@
         dirs.add(u);
       }
     }
-    return new URLClassLoader(
+    return URLClassLoader.newInstance(
         dirs.toArray(new URL[dirs.size()]), ClassLoader.getSystemClassLoader().getParent());
   }
 
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 5c6cb27..121b96b 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -14,30 +14,20 @@
 
 package com.google.gerrit.lucene;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
-import com.google.gerrit.server.index.SingleVersionModule;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
 import java.util.Map;
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
-public class LuceneIndexModule extends AbstractModule {
+public class LuceneIndexModule extends AbstractIndexModule {
   public static LuceneIndexModule singleVersionAllLatest(int threads, boolean slave) {
     return new LuceneIndexModule(ImmutableMap.of(), threads, false, slave);
   }
@@ -59,76 +49,40 @@
     return cfg.getBoolean("index", "lucene", "testInmemory", false);
   }
 
-  private final Map<String, Integer> singleVersions;
-  private final int threads;
-  private final boolean onlineUpgrade;
-  private final boolean slave;
-
   private LuceneIndexModule(
       Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
-    if (singleVersions != null) {
-      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
-    }
-    this.singleVersions = singleVersions;
-    this.threads = threads;
-    this.onlineUpgrade = onlineUpgrade;
-    this.slave = slave;
+    super(singleVersions, threads, onlineUpgrade, slave);
   }
 
   @Override
-  protected void configure() {
-    if (slave) {
-      bind(AccountIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
-      bind(ChangeIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
-      bind(ProjectIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
-    } else {
-      install(
-          new FactoryModuleBuilder()
-              .implement(AccountIndex.class, LuceneAccountIndex.class)
-              .build(AccountIndex.Factory.class));
-      install(
-          new FactoryModuleBuilder()
-              .implement(ChangeIndex.class, LuceneChangeIndex.class)
-              .build(ChangeIndex.Factory.class));
-      install(
-          new FactoryModuleBuilder()
-              .implement(ProjectIndex.class, LuceneProjectIndex.class)
-              .build(ProjectIndex.Factory.class));
-    }
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, LuceneGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModule(threads, slave));
-    if (singleVersions == null) {
-      install(new MultiVersionModule());
-    } else {
-      install(new SingleVersionModule(singleVersions));
-    }
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return LuceneAccountIndex.class;
   }
 
-  @SuppressWarnings("unused")
-  private static <T> T createDummyIndexFactory(Schema<?> schema) {
-    throw new UnsupportedOperationException();
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return LuceneChangeIndex.class;
   }
 
-  @Provides
-  @Singleton
-  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return LuceneGroupIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return LuceneProjectIndex.class;
+  }
+
+  @Override
+  protected Class<? extends VersionManager> getVersionManager() {
+    return LuceneVersionManager.class;
+  }
+
+  @Override
+  protected IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
     BooleanQuery.setMaxClauseCount(
         cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
-    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
-  }
-
-  private class MultiVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      bind(VersionManager.class).to(LuceneVersionManager.class);
-      listener().to(LuceneVersionManager.class);
-      if (onlineUpgrade) {
-        listener().to(OnlineUpgrader.class);
-      }
-    }
+    return super.getIndexConfig(cfg);
   }
 }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index a255020..76421fc 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -37,6 +37,7 @@
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
         "//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/restapi",
         "//java/com/google/gerrit/server/schema",
@@ -49,6 +50,7 @@
         "//lib:protobuf",
         "//lib:servlet-api-3_1-without-neverlink",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 1eb3e74..417b00d 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -49,12 +49,14 @@
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
 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.api.PluginApiModule;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -65,12 +67,12 @@
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
@@ -412,7 +414,7 @@
         inMemoryTest
             ? new InMemoryAccountPatchReviewStore.Module()
             : new JdbcAccountPatchReviewStore.Module(config));
-    modules.add(new ReceiveCommitsExecutorModule());
+    modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
@@ -422,7 +424,8 @@
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
-    modules.add(new DefaultCacheFactory.Module());
+    modules.add(new DefaultMemoryCacheModule());
+    modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     if (emailModule != null) {
       modules.add(emailModule);
@@ -478,7 +481,6 @@
       modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
-    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
     if (migrateToNoteDb()) {
       modules.add(new OnlineNoteDbMigrator.Module(trial));
     }
@@ -487,7 +489,8 @@
     }
     modules.add(new LocalMergeSuperSetComputation.Module());
     modules.add(new DefaultProjectNameLockManager.Module());
-    return cfgInjector.createChildInjector(modules);
+    return cfgInjector.createChildInjector(
+        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
 
   private boolean migrateToNoteDb() {
@@ -544,8 +547,8 @@
             slave,
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (!slave && indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
+    if (!slave) {
+      modules.add(new IndexCommandsModule(sysInjector));
     }
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 6e41a07..d0aed46 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -145,7 +145,7 @@
 
           if (sshKey != null) {
             VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
-            authorizedKeys.addKey(sshKey.getSshPublicKey());
+            authorizedKeys.addKey(sshKey.sshPublicKey());
             authorizedKeys.save("Add SSH key for initial admin user\n");
           }
 
@@ -165,8 +165,8 @@
 
   private String readEmail(AccountSshKey sshKey) {
     String defaultEmail = "admin@example.com";
-    if (sshKey != null && sshKey.getComment() != null) {
-      String c = sshKey.getComment().trim();
+    if (sshKey != null && sshKey.comment() != null) {
+      String c = sshKey.comment().trim();
       if (EmailValidator.getInstance().isValid(c)) {
         defaultEmail = c;
       }
@@ -199,6 +199,6 @@
       throw new IOException(String.format("Cannot add public SSH key: %s is not a file", keyFile));
     }
     String content = new String(Files.readAllBytes(p), UTF_8);
-    return new AccountSshKey(new AccountSshKey.Id(id, 1), content);
+    return AccountSshKey.create(id, 1, content);
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index 93e0f5d7..ee6c440 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -70,6 +70,7 @@
           "Transport protocol", "protocol", "http", Sets.newHashSet("http", "https"));
       defaultServer.string("Hostname", "hostname", "localhost");
       defaultServer.string("Port", "port", "9200");
+      index.string("Result window size", "maxLimit", "10000");
     }
 
     if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
diff --git a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index c1d142b..a7f9c5d 100644
--- a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -62,11 +62,10 @@
     return pluginsInitSteps;
   }
 
-  @SuppressWarnings("resource")
   private InitStep loadInitStep(Path jar) {
     try {
       URLClassLoader pluginLoader =
-          new URLClassLoader(
+          URLClassLoader.newInstance(
               new URL[] {jar.toUri().toURL()}, InitPluginStepsLoader.class.getClassLoader());
       try (JarFile jarFile = new JarFile(jar.toFile())) {
         Attributes jarFileAttributes = jarFile.getManifest().getMainAttributes();
diff --git a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index 757c9a4..a9c6cc8 100644
--- a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -66,8 +66,8 @@
   public AccountSshKey addKey(String pub) {
     checkState(keys != null, "SSH keys not loaded yet");
     int seq = keys.isEmpty() ? 1 : keys.size() + 1;
-    AccountSshKey.Id keyId = new AccountSshKey.Id(accountId, seq);
-    AccountSshKey key = new VersionedAuthorizedKeys.SimpleSshKeyCreator().create(keyId, pub);
+    AccountSshKey key =
+        new VersionedAuthorizedKeys.SimpleSshKeyCreator().create(accountId, seq, pub);
     keys.add(Optional.of(key));
     return key;
   }
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index d6e44bd..91647fb 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
         "//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/restapi",
         "//java/com/google/gerrit/server/schema",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index ffec375..17c7319 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -37,7 +37,8 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -51,13 +52,13 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
@@ -105,7 +106,7 @@
   protected void configure() {
     install(reviewDbModule);
     install(new DiffExecutorModule());
-    install(new ReceiveCommitsExecutorModule());
+    install(new SysExecutorModule());
     install(BatchUpdate.module());
     install(PatchListCacheImpl.module());
 
@@ -154,7 +155,8 @@
 
     install(new BatchGitModule());
     install(new DefaultPermissionBackendModule());
-    install(new DefaultCacheFactory.Module());
+    install(new DefaultMemoryCacheModule());
+    install(new H2CacheModule());
     install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
index de39839..8d04be8 100644
--- a/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -67,7 +67,7 @@
       if (!enabled) {
         return;
       }
-      //compress log once and then schedule compression every day at 11:00pm
+      // compress log once and then schedule compression every day at 11:00pm
       queue.getDefaultQueue().execute(compressor);
       ZoneId zone = ZoneId.systemDefault();
       LocalDateTime now = LocalDateTime.now(zone);
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
index 3739fd4..fd2fb56 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -258,6 +258,11 @@
     return isRefsGroups(ref) || isRefsDeletedGroups(ref) || REFS_GROUPNAMES.equals(ref);
   }
 
+  /** Whether the ref is the configuration branch, i.e. {@code refs/meta/config}, for a project. */
+  public static boolean isConfigRef(String ref) {
+    return REFS_CONFIG.equals(ref);
+  }
+
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index c96d61a..91d2d05 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,6 +27,12 @@
   }
 
   @Override
+  public Object getCacheKey() {
+    // Treat all anonymous users as a single user
+    return "anonymous";
+  }
+
+  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 94f5062..70b3d68 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -62,6 +62,7 @@
         "//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",
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 22d6167..4ee8d33 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -46,7 +46,7 @@
  * <p>During the transition phase, we have to keep these permissions in sync with the global
  * capabilities that serve as the source of truth.
  *
- * <p><This class implements a one-way synchronization from the the global {@code CREATE_GROUP}
+ * <p><This class implements a one-way synchronization from the global {@code CREATE_GROUP}
  * capability in {@code All-Projects} to a {@code CREATE} permission on {@code refs/groups/*} in
  * {@code All-Users}.
  */
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index ace06c5..eb3a3fd 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -90,6 +90,12 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
+  /**
+   * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
+   * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
+   */
+  public abstract Object getCacheKey();
+
   /** Unique name of the user on this server, if one has been assigned. */
   public Optional<String> getUserName() {
     return Optional.empty();
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java b/java/com/google/gerrit/server/FanOutExecutor.java
similarity index 72%
copy from java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
copy to java/com/google/gerrit/server/FanOutExecutor.java
index ee83a2c..a489890 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
+++ b/java/com/google/gerrit/server/FanOutExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 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,15 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git.receive;
+package com.google.gerrit.server;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
-import java.util.concurrent.ExecutorService;
 
-/** Marker on the global {@link ExecutorService} used by {@link ReceiveCommits}. */
+/**
+ * Marker on the global {@code ThreadPoolExecutor} used to do parallel work from a serving thread.
+ */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface ReceiveCommitsExecutor {}
+public @interface FanOutExecutor {}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 8379a7c..023e8e3 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -382,6 +382,11 @@
     return effectiveGroups;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getAccountId();
+  }
+
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 821a0c6..381819d 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,6 +36,11 @@
   }
 
   @Override
+  public String getCacheKey() {
+    return "internal";
+  }
+
+  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/java/com/google/gerrit/server/ModuleImpl.java b/java/com/google/gerrit/server/ModuleImpl.java
new file mode 100644
index 0000000..1614755
--- /dev/null
+++ b/java/com/google/gerrit/server/ModuleImpl.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.server;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Retention(RUNTIME)
+@Target(TYPE)
+@Inherited
+/**
+ * Use this annotation to mark module as being swappable with implementation from {@code
+ * gerrit.installModule}. Note that module with this annotation shouldn't be part of circular
+ * dependency with any existing module.
+ */
+public @interface ModuleImpl {
+  String name();
+}
diff --git a/java/com/google/gerrit/server/ModuleOverloader.java b/java/com/google/gerrit/server/ModuleOverloader.java
new file mode 100644
index 0000000..7083e6d
--- /dev/null
+++ b/java/com/google/gerrit/server/ModuleOverloader.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;
+
+import com.google.inject.Module;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class ModuleOverloader {
+  public static List<Module> override(List<Module> modules, List<Module> overrideCandidates) {
+    if (overrideCandidates == null || overrideCandidates.isEmpty()) {
+      return modules;
+    }
+
+    // group candidates by annotation existence
+    Map<Boolean, List<Module>> grouped =
+        overrideCandidates
+            .stream()
+            .collect(
+                Collectors.groupingBy(m -> m.getClass().getAnnotation(ModuleImpl.class) != null));
+
+    // add all non annotated libs to modules list
+    List<Module> libs = grouped.get(Boolean.FALSE);
+    if (libs != null) {
+      modules.addAll(libs);
+    }
+
+    List<Module> overrides = grouped.get(Boolean.TRUE);
+    if (overrides == null) {
+      return modules;
+    }
+
+    // swipe cache implementation with alternative provided in lib
+    return modules
+        .stream()
+        .map(
+            m -> {
+              ModuleImpl a = m.getClass().getAnnotation(ModuleImpl.class);
+              if (a == null) {
+                return m;
+              }
+              return overrides
+                  .stream()
+                  .filter(
+                      o ->
+                          o.getClass()
+                              .getAnnotation(ModuleImpl.class)
+                              .name()
+                              .equalsIgnoreCase(a.name()))
+                  .findFirst()
+                  .orElse(m);
+            })
+        .collect(Collectors.toList());
+  }
+
+  private ModuleOverloader() {}
+}
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index 8a8b67a..b27e05c 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,6 +40,11 @@
     return GroupMembership.EMPTY;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getRemoteAddress();
+  }
+
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index b6ca1cb..17493bf 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -16,7 +16,9 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
+import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
@@ -31,6 +33,20 @@
   Optional<AccountState> get(Account.Id accountId);
 
   /**
+   * Returns a {@code Map} of {@code Account.Id} to {@code AccountState} for the given account IDs.
+   * If not cached yet the accounts are loaded. If an account can't be loaded (e.g. because it is
+   * missing), the entry will be missing from the result.
+   *
+   * <p>Loads accounts in parallel if applicable.
+   *
+   * @param accountIds IDs of the account that should be retrieved
+   * @return {@code Map} of {@code Account.Id} to {@code AccountState} instances for the given
+   *     account IDs, if an account can't be loaded (e.g. because it is missing), the entry will be
+   *     missing from the result
+   */
+  Map<Account.Id, AccountState> get(Set<Account.Id> accountIds);
+
+  /**
    * Returns an {@code AccountState} instance for the given account ID. If not cached yet the
    * account is loaded. Returns an empty {@code AccountState} instance to represent a missing
    * account.
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 105a457..0648f9f 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -18,9 +18,11 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
@@ -31,8 +33,16 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -60,15 +70,18 @@
   private final AllUsersName allUsersName;
   private final ExternalIds externalIds;
   private final LoadingCache<Account.Id, Optional<AccountState>> byId;
+  private final ExecutorService executor;
 
   @Inject
   AccountCacheImpl(
       AllUsersName allUsersName,
       ExternalIds externalIds,
-      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId) {
+      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
+      @FanOutExecutor ExecutorService executor) {
     this.allUsersName = allUsersName;
     this.externalIds = externalIds;
     this.byId = byId;
+    this.executor = executor;
   }
 
   @Override
@@ -92,13 +105,47 @@
   }
 
   @Override
+  public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
+    Map<Account.Id, AccountState> accountStates = new HashMap<>(accountIds.size());
+    List<Callable<Optional<AccountState>>> callables = new ArrayList<>();
+    for (Account.Id accountId : accountIds) {
+      Optional<AccountState> state = byId.getIfPresent(accountId);
+      if (state != null) {
+        // The value is in-memory, so we just get the state
+        state.ifPresent(s -> accountStates.put(accountId, s));
+      } else {
+        // Queue up a callable so that we can load accounts in parallel
+        callables.add(() -> get(accountId));
+      }
+    }
+    if (callables.isEmpty()) {
+      return accountStates;
+    }
+
+    List<Future<Optional<AccountState>>> futures;
+    try {
+      futures = executor.invokeAll(callables);
+    } catch (InterruptedException e) {
+      log.error("Cannot load AccountStates", e);
+      return ImmutableMap.of();
+    }
+    for (Future<Optional<AccountState>> f : futures) {
+      try {
+        f.get().ifPresent(s -> accountStates.put(s.getAccount().getId(), s));
+      } catch (InterruptedException | ExecutionException e) {
+        log.error("Cannot load AccountState", e);
+      }
+    }
+    return accountStates;
+  }
+
+  @Override
   public Optional<AccountState> getByUsername(String username) {
     try {
-      ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
-      if (extId == null) {
-        return Optional.empty();
-      }
-      return get(extId.accountId());
+      return externalIds
+          .get(ExternalId.Key.create(SCHEME_USERNAME, username))
+          .map(e -> get(e.accountId()))
+          .orElseGet(Optional::empty);
     } catch (IOException | ConfigInvalidException e) {
       log.warn("Cannot load AccountState for username " + username, e);
       return null;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index d54cbbc..009623a 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -112,8 +112,7 @@
   /** @return user identified by this external identity string */
   public Optional<Account.Id> lookup(String externalId) throws AccountException {
     try {
-      ExternalId extId = externalIds.get(ExternalId.Key.parse(externalId));
-      return extId != null ? Optional.of(extId.accountId()) : Optional.empty();
+      return externalIds.get(ExternalId.Key.parse(externalId)).map(ExternalId::accountId);
     } catch (IOException | ConfigInvalidException e) {
       throw new AccountException("Cannot lookup account " + externalId, e);
     }
@@ -136,32 +135,33 @@
       throw e;
     }
     try {
-      ExternalId id = externalIds.get(who.getExternalIdKey());
-      if (id == null) {
+      Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+      if (!optionalExtId.isPresent()) {
         if (who.getUserName().isPresent()) {
           ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, who.getUserName().get());
-          ExternalId existingId = externalIds.get(key);
-          if (existingId != null) {
+          Optional<ExternalId> existingId = externalIds.get(key);
+          if (existingId.isPresent()) {
             // An inconsistency is detected in the database, having a record for scheme "username:"
             // but no record for scheme "gerrit:". Try to recover by linking
             // "gerrit:" identity to the existing account.
             log.warn(
                 "User {} already has an account; link new identity to the existing account.",
                 who.getUserName());
-            return link(existingId.accountId(), who);
+            return link(existingId.get().accountId(), who);
           }
         }
         // New account, automatically create and return.
-        log.info("External ID not found. Attempting to create new account.");
+        log.debug("External ID not found. Attempting to create new account.");
         return create(who);
       }
 
-      Optional<AccountState> accountState = byIdCache.get(id.accountId());
+      ExternalId extId = optionalExtId.get();
+      Optional<AccountState> accountState = byIdCache.get(extId.accountId());
       if (!accountState.isPresent()) {
         log.error(
-            String.format(
-                "Authentication with external ID %s failed. Account %s doesn't exist.",
-                id.key().get(), id.accountId().get()));
+            "Authentication with external ID {} failed. Account {} doesn't exist.",
+            extId.key().get(),
+            extId.accountId().get());
         throw new AccountException("Authentication error, account not found");
       }
 
@@ -177,8 +177,8 @@
       }
 
       // return the identity to the caller.
-      update(who, id);
-      return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
+      update(who, extId);
+      return new AuthResult(extId.accountId(), who.getExternalIdKey(), false);
     } catch (OrmException | ConfigInvalidException e) {
       throw new AccountException("Authentication error", e);
     }
@@ -189,11 +189,11 @@
       return;
     }
     try {
-      ExternalId id = externalIds.get(authRequest.getExternalIdKey());
-      if (id == null) {
+      Optional<ExternalId> extId = externalIds.get(authRequest.getExternalIdKey());
+      if (!extId.isPresent()) {
         return;
       }
-      setInactiveFlag.deactivate(id.accountId());
+      setInactiveFlag.deactivate(extId.get().accountId());
     } catch (Exception e) {
       log.error(
           "Unable to deactivate account "
@@ -285,9 +285,11 @@
   private AuthResult create(AuthRequest who)
       throws OrmException, AccountException, IOException, ConfigInvalidException {
     Account.Id newId = new Account.Id(sequences.nextAccountId());
+    log.debug("Assigning new Id {} to account", newId);
 
     ExternalId extId =
         ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
+    log.debug("Created external Id: {}", extId);
     checkEmailNotUsed(extId);
     ExternalId userNameExtId =
         who.getUserName().isPresent() ? createUsername(newId, who.getUserName().get()) : null;
@@ -411,16 +413,17 @@
    */
   public AuthResult link(Account.Id to, AuthRequest who)
       throws AccountException, OrmException, IOException, ConfigInvalidException {
-    ExternalId extId = externalIds.get(who.getExternalIdKey());
-    log.info("Link another authentication identity to an existing account");
-    if (extId != null) {
+    Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+    log.debug("Link another authentication identity to an existing account");
+    if (optionalExtId.isPresent()) {
+      ExternalId extId = optionalExtId.get();
       if (!extId.accountId().equals(to)) {
         throw new AccountException(
             "Identity '" + extId.key().get() + "' in use by another account");
       }
       update(who, extId);
     } else {
-      log.info("Linking new external ID to the existing account");
+      log.debug("Linking new external ID to the existing account");
       ExternalId newExtId =
           ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
       checkEmailNotUsed(newExtId);
@@ -506,12 +509,12 @@
 
     List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
     for (ExternalId.Key extIdKey : extIdKeys) {
-      ExternalId extId = externalIds.get(extIdKey);
-      if (extId != null) {
-        if (!extId.accountId().equals(from)) {
+      Optional<ExternalId> extId = externalIds.get(extIdKey);
+      if (extId.isPresent()) {
+        if (!extId.get().accountId().equals(from)) {
           throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
         }
-        extIds.add(extId);
+        extIds.add(extId.get());
       } else {
         throw new AccountException("Identity '" + extIdKey.get() + "' not found");
       }
diff --git a/java/com/google/gerrit/server/account/AccountSshKey.java b/java/com/google/gerrit/server/account/AccountSshKey.java
index aeccc0a..f132585 100644
--- a/java/com/google/gerrit/server/account/AccountSshKey.java
+++ b/java/com/google/gerrit/server/account/AccountSshKey.java
@@ -14,76 +14,50 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Account;
-import java.io.Serializable;
 import java.util.List;
-import java.util.Objects;
 
 /** An SSH key approved for use by an {@link Account}. */
-public final class AccountSshKey {
-  public static class Id implements Serializable {
-    private static final long serialVersionUID = 2L;
-
-    private Account.Id accountId;
-    private int seq;
-
-    public Id(Account.Id a, int s) {
-      accountId = a;
-      seq = s;
-    }
-
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public int get() {
-      return seq;
-    }
-
-    public boolean isValid() {
-      return seq > 0;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(accountId, seq);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof Id)) {
-        return false;
-      }
-      Id otherId = (Id) obj;
-      return Objects.equals(accountId, otherId.accountId) && Objects.equals(seq, otherId.seq);
-    }
+@AutoValue
+public abstract class AccountSshKey {
+  public static AccountSshKey create(Account.Id accountId, int seq, String sshPublicKey) {
+    return create(accountId, seq, sshPublicKey, true);
   }
 
-  private AccountSshKey.Id id;
-  private String sshPublicKey;
-  private boolean valid;
-
-  public AccountSshKey(AccountSshKey.Id i, String pub) {
-    id = i;
-    sshPublicKey = pub.replace("\n", "").replace("\r", "");
-    valid = id.isValid();
+  public static AccountSshKey createInvalid(Account.Id accountId, int seq, String sshPublicKey) {
+    return create(accountId, seq, sshPublicKey, false);
   }
 
-  public Account.Id getAccount() {
-    return id.accountId;
+  public static AccountSshKey createInvalid(AccountSshKey key) {
+    return create(key.accountId(), key.seq(), key.sshPublicKey(), false);
   }
 
-  public AccountSshKey.Id getKey() {
-    return id;
+  public static AccountSshKey create(
+      Account.Id accountId, int seq, String sshPublicKey, boolean valid) {
+    return new AutoValue_AccountSshKey.Builder()
+        .setAccountId(accountId)
+        .setSeq(seq)
+        .setSshPublicKey(stripOffNewLines(sshPublicKey))
+        .setValid(valid && seq > 0)
+        .build();
   }
 
-  public String getSshPublicKey() {
-    return sshPublicKey;
+  private static String stripOffNewLines(String s) {
+    return s.replace("\n", "").replace("\r", "");
   }
 
-  private String getPublicKeyPart(int index, String defaultValue) {
-    String s = getSshPublicKey();
+  public abstract Account.Id accountId();
+
+  public abstract int seq();
+
+  public abstract String sshPublicKey();
+
+  public abstract boolean valid();
+
+  private String publicKeyPart(int index, String defaultValue) {
+    String s = sshPublicKey();
     if (s != null && s.length() > 0) {
       List<String> parts = Splitter.on(' ').splitToList(s);
       if (parts.size() > index) {
@@ -93,39 +67,28 @@
     return defaultValue;
   }
 
-  public String getAlgorithm() {
-    return getPublicKeyPart(0, "none");
+  public String algorithm() {
+    return publicKeyPart(0, "none");
   }
 
-  public String getEncodedKey() {
-    return getPublicKeyPart(1, null);
+  public String encodedKey() {
+    return publicKeyPart(1, null);
   }
 
-  public String getComment() {
-    return getPublicKeyPart(2, "");
+  public String comment() {
+    return publicKeyPart(2, "");
   }
 
-  public boolean isValid() {
-    return valid && id.isValid();
-  }
+  @AutoValue.Builder
+  abstract static class Builder {
+    public abstract Builder setAccountId(Account.Id accountId);
 
-  public void setInvalid() {
-    valid = false;
-  }
+    public abstract Builder setSeq(int seq);
 
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof AccountSshKey) {
-      AccountSshKey other = (AccountSshKey) o;
-      return Objects.equals(id, other.id)
-          && Objects.equals(sshPublicKey, other.sshPublicKey)
-          && Objects.equals(valid, other.valid);
-    }
-    return false;
-  }
+    public abstract Builder setSshPublicKey(String sshPublicKey);
 
-  @Override
-  public int hashCode() {
-    return Objects.hash(id, sshPublicKey, valid);
+    public abstract Builder setValid(boolean valid);
+
+    public abstract AccountSshKey build();
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 5b9ea69..14a0e92 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -111,7 +111,6 @@
 
     // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
     // an open Repository instance.
-    // TODO(ekempin): Find a way to lazily compute these that doesn't hold the repo open.
     ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
         accountConfig.getProjectWatches();
     GeneralPreferencesInfo generalPreferences = accountConfig.getGeneralPreferences();
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 3554b60..2f36cf2 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.InternalAccountUpdate.Builder;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -146,10 +147,17 @@
      * @param accountState the account that is being updated
      * @param update account update builder
      */
-    void update(AccountState accountState, InternalAccountUpdate.Builder update);
+    void update(AccountState accountState, InternalAccountUpdate.Builder update) throws IOException;
 
     static AccountUpdater join(List<AccountUpdater> updaters) {
-      return (a, u) -> updaters.stream().forEach(updater -> updater.update(a, u));
+      return new AccountUpdater() {
+        @Override
+        public void update(AccountState accountState, Builder update) throws IOException {
+          for (AccountUpdater updater : updaters) {
+            updater.update(accountState, update);
+          }
+        }
+      };
     }
 
     static AccountUpdater joinConsumers(List<Consumer<InternalAccountUpdate.Builder>> consumers) {
diff --git a/java/com/google/gerrit/server/account/AuthorizedKeys.java b/java/com/google/gerrit/server/account/AuthorizedKeys.java
index 3a6c032..b392c18 100644
--- a/java/com/google/gerrit/server/account/AuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -41,8 +41,7 @@
         continue;
       } else if (line.startsWith(INVALID_KEY_COMMENT_PREFIX)) {
         String pub = line.substring(INVALID_KEY_COMMENT_PREFIX.length());
-        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq++), pub);
-        key.setInvalid();
+        AccountSshKey key = AccountSshKey.createInvalid(accountId, seq++, pub);
         keys.add(Optional.of(key));
       } else if (line.startsWith(DELETED_KEY_COMMENT)) {
         keys.add(Optional.empty());
@@ -50,7 +49,7 @@
       } else if (line.startsWith("#")) {
         continue;
       } else {
-        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq++), line);
+        AccountSshKey key = AccountSshKey.create(accountId, seq++, line);
         keys.add(Optional.of(key));
       }
     }
@@ -61,10 +60,10 @@
     StringBuilder b = new StringBuilder();
     for (Optional<AccountSshKey> key : keys) {
       if (key.isPresent()) {
-        if (!key.get().isValid()) {
+        if (!key.get().valid()) {
           b.append(INVALID_KEY_COMMENT_PREFIX);
         }
-        b.append(key.get().getSshPublicKey().trim());
+        b.append(key.get().sshPublicKey().trim());
       } else {
         b.append(DELETED_KEY_COMMENT);
       }
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 9d9cf23..dde6e81 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
@@ -33,7 +34,8 @@
   private final AuthConfig authConfig;
 
   @Inject
-  DefaultRealm(EmailExpander emailExpander, Provider<Emails> emails, AuthConfig authConfig) {
+  @VisibleForTesting
+  public DefaultRealm(EmailExpander emailExpander, Provider<Emails> emails, AuthConfig authConfig) {
     this.emailExpander = emailExpander;
     this.emails = emails;
     this.authConfig = authConfig;
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 77752da..8c2bc10 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.account;
 
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
+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;
@@ -32,7 +34,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
-import java.util.Optional;
+import java.util.Map;
 import java.util.Set;
 
 @Singleton
@@ -66,11 +68,14 @@
     if (options.equals(ID_ONLY)) {
       return;
     }
+    Set<Account.Id> ids =
+        Streams.stream(in).map(a -> new Account.Id(a._accountId)).collect(toSet());
+    Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
       Account.Id id = new Account.Id(info._accountId);
-      Optional<AccountState> state = accountCache.get(id);
-      if (state.isPresent()) {
-        fill(info, state.get(), options);
+      AccountState state = accountStates.get(id);
+      if (state != null) {
+        fill(info, accountStates.get(id), options);
       } else {
         info._accountId = options.contains(FillOptions.ID) ? id.get() : null;
       }
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index 1481379..a57dc7b 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -26,7 +26,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-/** Preferences for user accounts. */
+/** User configured named destinations. */
 public class VersionedAccountDestinations extends VersionedMetaData {
   private static final Logger log = LoggerFactory.getLogger(VersionedAccountDestinations.class);
 
@@ -52,6 +52,9 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision == null) {
+      return;
+    }
     String prefix = DestinationList.DIR_NAME + "/";
     for (PathInfo p : getPathInfos(true)) {
       if (p.fileMode == FileMode.REGULAR_FILE) {
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 6a32f57..c7ffa55 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey.Id;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -139,8 +138,8 @@
 
   public static class SimpleSshKeyCreator implements SshKeyCreator {
     @Override
-    public AccountSshKey create(Id id, String encoded) {
-      return new AccountSshKey(id, encoded);
+    public AccountSshKey create(Account.Id accountId, int seq, String encoded) {
+      return AccountSshKey.create(accountId, seq, encoded);
     }
   }
 
@@ -211,14 +210,13 @@
     checkLoaded();
 
     for (Optional<AccountSshKey> key : keys) {
-      if (key.isPresent() && key.get().getSshPublicKey().trim().equals(pub.trim())) {
+      if (key.isPresent() && key.get().sshPublicKey().trim().equals(pub.trim())) {
         return key.get();
       }
     }
 
     int seq = keys.size() + 1;
-    AccountSshKey.Id keyId = new AccountSshKey.Id(accountId, seq);
-    AccountSshKey key = sshKeyCreator.create(keyId, pub);
+    AccountSshKey key = sshKeyCreator.create(accountId, seq, pub);
     keys.add(Optional.of(key));
     return key;
   }
@@ -249,9 +247,10 @@
    */
   private boolean markKeyInvalid(int seq) {
     checkLoaded();
-    AccountSshKey key = getKey(seq);
-    if (key != null && key.isValid()) {
-      key.setInvalid();
+
+    Optional<AccountSshKey> key = keys.get(seq - 1);
+    if (key.isPresent() && key.get().valid()) {
+      keys.set(seq - 1, Optional.of(AccountSshKey.createInvalid(key.get())));
       return true;
     }
     return false;
@@ -265,10 +264,10 @@
    * @param newKeys the new public SSH keys
    */
   public void setKeys(Collection<AccountSshKey> newKeys) {
-    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
-    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).getKey().get(), Optional.empty()));
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.seq()));
+    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).seq(), Optional.empty()));
     for (AccountSshKey key : newKeys) {
-      keys.set(key.getKey().get() - 1, Optional.of(key));
+      keys.set(key.seq() - 1, Optional.of(key));
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index 8d040d6..ee6d5cd 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -27,6 +27,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -122,22 +123,21 @@
   }
 
   /** Reads and returns the specified external ID. */
-  @Nullable
-  ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+  Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo).get(key).orElse(null);
+      return ExternalIdNotes.loadReadOnly(repo).get(key);
     }
   }
 
   /** Reads and returns the specified external ID from the given revision. */
-  @Nullable
-  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+  Optional<ExternalId> get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo, rev).get(key).orElse(null);
+      return ExternalIdNotes.loadReadOnly(repo, rev).get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 167af45..b1a59b1 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -18,11 +18,11 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -54,14 +54,12 @@
   }
 
   /** Returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+  public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     return externalIdReader.get(key);
   }
 
   /** Returns the specified external ID from the given revision. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key, ObjectId rev)
+  public Optional<ExternalId> get(ExternalId.Key key, ObjectId rev)
       throws IOException, ConfigInvalidException {
     return externalIdReader.get(key, rev);
   }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 366ebfb..12c65ca 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
@@ -124,6 +125,7 @@
   private final DeleteExternalIds deleteExternalIds;
   private final PutStatus putStatus;
   private final GetGroups getGroups;
+  private final EmailApiImpl.Factory emailApi;
 
   @Inject
   AccountApiImpl(
@@ -162,6 +164,7 @@
       DeleteExternalIds deleteExternalIds,
       PutStatus putStatus,
       GetGroups getGroups,
+      EmailApiImpl.Factory emailApi,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -199,6 +202,7 @@
     this.deleteExternalIds = deleteExternalIds;
     this.putStatus = putStatus;
     this.getGroups = getGroups;
+    this.emailApi = emailApi;
   }
 
   @Override
@@ -411,6 +415,26 @@
   }
 
   @Override
+  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);
+      return email(rsrc.getEmail());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create email", e);
+    }
+  }
+
+  @Override
+  public EmailApi email(String email) throws RestApiException {
+    try {
+      return emailApi.create(account, email);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse email", e);
+    }
+  }
+
+  @Override
   public void setStatus(String status) throws RestApiException {
     StatusInput in = new StatusInput(status);
     try {
diff --git a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
new file mode 100644
index 0000000..759f60c
--- /dev/null
+++ b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
@@ -0,0 +1,91 @@
+// 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.api.accounts;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.accounts.EmailApi;
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.account.DeleteEmail;
+import com.google.gerrit.server.restapi.account.EmailsCollection;
+import com.google.gerrit.server.restapi.account.GetEmail;
+import com.google.gerrit.server.restapi.account.PutPreferred;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class EmailApiImpl implements EmailApi {
+  interface Factory {
+    EmailApiImpl create(AccountResource account, String email);
+  }
+
+  private final EmailsCollection emails;
+  private final GetEmail get;
+  private final DeleteEmail delete;
+  private final PutPreferred putPreferred;
+  private final AccountResource account;
+  private final String email;
+
+  @Inject
+  EmailApiImpl(
+      EmailsCollection emails,
+      GetEmail get,
+      DeleteEmail delete,
+      PutPreferred putPreferred,
+      @Assisted AccountResource account,
+      @Assisted String email) {
+    this.emails = emails;
+    this.get = get;
+    this.delete = delete;
+    this.putPreferred = putPreferred;
+    this.account = account;
+    this.email = email;
+  }
+
+  @Override
+  public EmailInfo get() throws RestApiException {
+    try {
+      return get.apply(resource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot read email", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      delete.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete email", e);
+    }
+  }
+
+  @Override
+  public void setPreferred() throws RestApiException {
+    try {
+      putPreferred.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException(String.format("Cannot set %s as preferred email", email), e);
+    }
+  }
+
+  private AccountResource.Email resource() throws RestApiException, PermissionBackendException {
+    return emails.parse(account, IdString.fromDecoded(email));
+  }
+}
diff --git a/java/com/google/gerrit/server/api/accounts/Module.java b/java/com/google/gerrit/server/api/accounts/Module.java
index 935c4d7..15c6ddb 100644
--- a/java/com/google/gerrit/server/api/accounts/Module.java
+++ b/java/com/google/gerrit/server/api/accounts/Module.java
@@ -23,5 +23,6 @@
     bind(Accounts.class).to(AccountsImpl.class);
 
     factory(AccountApiImpl.Factory.class);
+    factory(EmailApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index e5e2405..6184674 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -351,7 +351,8 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      return Optional.ofNullable(externalIds.get(ExternalId.Key.create(SCHEME_GERRIT, username)))
+      return externalIds
+          .get(ExternalId.Key.create(SCHEME_GERRIT, username))
           .map(ExternalId::accountId);
     }
   }
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index e68eb43..0e0c16f 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -31,6 +31,9 @@
 
 /** Miniature DSL to support binding {@link Cache} instances in Guice. */
 public abstract class CacheModule extends FactoryModule {
+  public static final String MEMORY_MODULE = "cache-memory";
+  public static final String PERSISTENT_MODULE = "cache-persistent";
+
   private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<Cache<?, ?>>() {};
 
   /**
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
new file mode 100644
index 0000000..32dff38
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.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.server.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
+ *
+ * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
+ * the current request and potentially need to be instantiated multiple times while serving a
+ * request.
+ *
+ * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
+ * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
+ * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
+ * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
+ * shared between the request serving thread as well as sub- or background treads.
+ *
+ * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
+ * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
+ * has the downside of not sharing any objects with background threads or executors.
+ *
+ * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
+ * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
+ * just retrieving stored values is a valid operation.
+ */
+public class PerThreadCache implements AutoCloseable {
+  private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
+
+  /**
+   * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
+   * class and a list of identifiers that in combination uniquely set the object apart form others
+   * of the same class.
+   */
+  public static final class Key<T> {
+    private final Class<T> clazz;
+    private final ImmutableList<Object> identifiers;
+
+    /**
+     * Returns a key based on the value's class and an identifier that uniquely identify the value.
+     * The identifier needs to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object identifier) {
+      return new Key<>(clazz, ImmutableList.of(identifier));
+    }
+
+    /**
+     * Returns a key based on the value's class and a set of identifiers that uniquely identify the
+     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
+      return new Key<>(clazz, ImmutableList.copyOf(identifiers));
+    }
+
+    private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
+      this.clazz = clazz;
+      this.identifiers = identifiers;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(clazz, identifiers);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key<?> other = (Key<?>) o;
+      return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
+    }
+  }
+
+  public static PerThreadCache create() {
+    checkState(CACHE.get() == null, "called create() twice on the same request");
+    PerThreadCache cache = new PerThreadCache();
+    CACHE.set(cache);
+    return cache;
+  }
+
+  @Nullable
+  public static PerThreadCache get() {
+    return CACHE.get();
+  }
+
+  public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
+    PerThreadCache cache = get();
+    return cache != null ? cache.get(key, loader) : loader.get();
+  }
+
+  private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(10);
+
+  private PerThreadCache() {}
+
+  /**
+   * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
+   * provided {@link Supplier}.
+   */
+  public <T> T get(Key<T> key, Supplier<T> loader) {
+    @SuppressWarnings("unchecked")
+    T value = (T) cache.get(key);
+    if (value == null) {
+      value = loader.get();
+      cache.put(key, value);
+    }
+    return value;
+  }
+
+  @Override
+  public void close() {
+    CACHE.remove();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index c52c232..d134adf 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -17,12 +17,11 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.gerrit.server.plugins.Plugin;
 
 public interface PersistentCacheFactory {
   <K, V> Cache<K, V> build(CacheBinding<K, V> def);
 
   <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader);
 
-  void onStop(Plugin plugin);
+  void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java
new file mode 100644
index 0000000..0d1cf20
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.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.server.cache.h2;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.inject.TypeLiteral;
+import java.util.concurrent.TimeUnit;
+
+class H2CacheBindingProxy<K, V> implements CacheBinding<K, V> {
+  private static final String MSG_NOT_SUPPORTED =
+      "This is read-only wrapper. Modifications are not supported";
+
+  private final CacheBinding<K, V> source;
+
+  H2CacheBindingProxy(CacheBinding<K, V> source) {
+    this.source = source;
+  }
+
+  @Override
+  public Long expireAfterWrite(TimeUnit unit) {
+    return source.expireAfterWrite(unit);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Weigher<K, V> weigher() {
+    Weigher<K, V> weigher = source.weigher();
+    if (weigher == null) {
+      return null;
+    }
+
+    // introduce weigher that performs calculations
+    // on value that is being stored not on ValueHolder
+    return (Weigher<K, V>)
+        new Weigher<K, ValueHolder<V>>() {
+          @Override
+          public int weigh(K key, ValueHolder<V> value) {
+            return weigher.weigh(key, value.value);
+          }
+        };
+  }
+
+  @Override
+  public String name() {
+    return source.name();
+  }
+
+  @Override
+  public TypeLiteral<K> keyType() {
+    return source.keyType();
+  }
+
+  @Override
+  public TypeLiteral<V> valueType() {
+    return source.valueType();
+  }
+
+  @Override
+  public long maximumWeight() {
+    return source.maximumWeight();
+  }
+
+  @Override
+  public long diskLimit() {
+    return source.diskLimit();
+  }
+
+  @Override
+  public CacheLoader<K, V> loader() {
+    return source.loader();
+  }
+
+  @Override
+  public CacheBinding<K, V> maximumWeight(long weight) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> diskLimit(long limit) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 1283452..2240c7d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 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.plugins.Plugin;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,7 +50,7 @@
 class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
   private static final Logger log = LoggerFactory.getLogger(H2CacheFactory.class);
 
-  private final DefaultCacheFactory defaultFactory;
+  private final MemoryCacheFactory memCacheFactory;
   private final Config config;
   private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
@@ -62,11 +62,11 @@
 
   @Inject
   H2CacheFactory(
-      DefaultCacheFactory defaultCacheFactory,
+      MemoryCacheFactory memCacheFactory,
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    defaultFactory = defaultCacheFactory;
+    this.memCacheFactory = memCacheFactory;
     config = cfg;
     cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
@@ -154,21 +154,19 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
-    long limit = config.getLong("cache", def.name(), "diskLimit", 128 << 20);
+  public <K, V> Cache<K, V> build(CacheBinding<K, V> in) {
+    long limit = config.getLong("cache", in.name(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return defaultFactory.build(def);
+      return memCacheFactory.build(in);
     }
 
+    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
     SqlStore<K, V> store =
         newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
-            executor,
-            store,
-            def.keyType(),
-            (Cache<K, ValueHolder<V>>) defaultFactory.create(def, true).build());
+            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
     synchronized (caches) {
       caches.add(cache);
     }
@@ -177,30 +175,31 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader) {
-    long limit = config.getLong("cache", def.name(), "diskLimit", def.diskLimit());
+  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> in, CacheLoader<K, V> loader) {
+    long limit = config.getLong("cache", in.name(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return defaultFactory.build(def, loader);
+      return memCacheFactory.build(in, loader);
     }
 
+    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
     SqlStore<K, V> store =
         newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
-            defaultFactory
-                .create(def, true)
-                .build((CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
+            memCacheFactory.build(
+                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
     H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
-    caches.add(cache);
+    synchronized (caches) {
+      caches.add(cache);
+    }
     return cache;
   }
 
   @Override
-  public void onStop(Plugin plugin) {
+  public void onStop(String plugin) {
     synchronized (caches) {
-      for (Map.Entry<String, Provider<Cache<?, ?>>> entry :
-          cacheMap.byPlugin(plugin.getName()).entrySet()) {
+      for (Map.Entry<String, Provider<Cache<?, ?>>> entry : cacheMap.byPlugin(plugin).entrySet()) {
         Cache<?, ?> cache = entry.getValue().get();
         if (caches.remove(cache)) {
           ((H2CacheImpl<?, ?>) cache).stop();
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheModule.java b/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
new file mode 100644
index 0000000..f605578
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheModule.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.cache.h2;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.PersistentCacheFactory;
+
+@ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+public class H2CacheModule extends LifecycleModule {
+  @Override
+  protected void configure() {
+    bind(PersistentCacheFactory.class).to(H2CacheFactory.class);
+    listener().to(H2CacheFactory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/mem/BUILD b/java/com/google/gerrit/server/cache/mem/BUILD
new file mode 100644
index 0000000..ef297f1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/mem/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "mem",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
similarity index 70%
rename from java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
rename to java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 3566955..114e893 100644
--- a/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.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.h2;
+package com.google.gerrit.server.cache.mem;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
@@ -20,35 +20,21 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.Weigher;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.cache.CacheBinding;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
-import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
-public class DefaultCacheFactory implements MemoryCacheFactory {
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      factory(ForwardingRemovalListener.Factory.class);
-      bind(DefaultCacheFactory.class);
-      bind(MemoryCacheFactory.class).to(DefaultCacheFactory.class);
-      bind(PersistentCacheFactory.class).to(H2CacheFactory.class);
-      listener().to(H2CacheFactory.class);
-    }
-  }
-
+class DefaultMemoryCacheFactory implements MemoryCacheFactory {
   private final Config cfg;
   private final ForwardingRemovalListener.Factory forwardingRemovalListenerFactory;
 
   @Inject
-  public DefaultCacheFactory(
+  DefaultMemoryCacheFactory(
       @GerritServerConfig Config config,
       ForwardingRemovalListener.Factory forwardingRemovalListenerFactory) {
     this.cfg = config;
@@ -57,16 +43,16 @@
 
   @Override
   public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
-    return create(def, false).build();
+    return create(def).build();
   }
 
   @Override
   public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader) {
-    return create(def, false).build(loader);
+    return create(def).build(loader);
   }
 
   @SuppressWarnings("unchecked")
-  <K, V> CacheBuilder<K, V> create(CacheBinding<K, V> def, boolean unwrapValueHolder) {
+  private <K, V> CacheBuilder<K, V> create(CacheBinding<K, V> def) {
     CacheBuilder<K, V> builder = newCacheBuilder();
     builder.recordStats();
     builder.maximumWeight(cfg.getLong("cache", def.name(), "memoryLimit", def.maximumWeight()));
@@ -74,17 +60,7 @@
     builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
 
     Weigher<K, V> weigher = def.weigher();
-    if (weigher != null && unwrapValueHolder) {
-      final Weigher<K, V> impl = weigher;
-      weigher =
-          (Weigher<K, V>)
-              new Weigher<K, ValueHolder<V>>() {
-                @Override
-                public int weigh(K key, ValueHolder<V> value) {
-                  return impl.weigh(key, value.value);
-                }
-              };
-    } else if (weigher == null) {
+    if (weigher == null) {
       weigher = unitWeight();
     }
     builder.weigher(weigher);
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java
new file mode 100644
index 0000000..7beb0bb
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java
@@ -0,0 +1,30 @@
+// 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.mem;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.ForwardingRemovalListener;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
+
+@ModuleImpl(name = CacheModule.MEMORY_MODULE)
+public class DefaultMemoryCacheModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    factory(ForwardingRemovalListener.Factory.class);
+    bind(MemoryCacheFactory.class).to(DefaultMemoryCacheFactory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 4748911..a08203e 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -41,13 +41,13 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 19c9666..82affe0 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -44,7 +44,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -63,6 +62,8 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -78,6 +79,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
@@ -87,6 +89,10 @@
 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.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -99,6 +105,7 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -122,7 +129,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RemoveReviewerControl;
@@ -148,6 +154,10 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -156,6 +166,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/**
+ * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
+ *
+ * <p>This is intended to be used on request scope, but may be used for converting multiple {@link
+ * ChangeData} objects from different sources.
+ */
 public class ChangeJson {
   private static final Logger log = LoggerFactory.getLogger(ChangeJson.class);
 
@@ -202,6 +218,35 @@
     ChangeJson create(Iterable<ListChangesOption> options);
   }
 
+  @Singleton
+  private static class Metrics {
+    private final Timer0 toChangeInfoLatency;
+    private final Timer0 toChangeInfosLatency;
+    private final Timer0 formatQueryResultsLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      toChangeInfoLatency =
+          metricMaker.newTimer(
+              "http/server/rest_api/change_json/to_change_info_latency",
+              new Description("Latency for toChangeInfo invocations in ChangeJson")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      toChangeInfosLatency =
+          metricMaker.newTimer(
+              "http/server/rest_api/change_json/to_change_infos_latency",
+              new Description("Latency for toChangeInfos invocations in ChangeJson")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      formatQueryResultsLatency =
+          metricMaker.newTimer(
+              "http/server/rest_api/change_json/format_query_results_latency",
+              new Description("Latency for formatQueryResults invocations in ChangeJson")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+    }
+  }
+
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
   private final AnonymousUser anonymous;
@@ -228,6 +273,9 @@
   private final ApprovalsUtil approvalsUtil;
   private final RemoveReviewerControl removeReviewerControl;
   private final TrackingFooters trackingFooters;
+  private final Metrics metrics;
+  private final ExecutorService fanOutExecutor;
+
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -260,6 +308,8 @@
       ApprovalsUtil approvalsUtil,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
+      Metrics metrics,
+      @FanOutExecutor ExecutorService fanOutExecutor,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
     this.userProvider = user;
@@ -285,10 +335,16 @@
     this.indexes = indexes;
     this.approvalsUtil = approvalsUtil;
     this.removeReviewerControl = removeReviewerControl;
-    this.options = Sets.immutableEnumSet(options);
     this.trackingFooters = trackingFooters;
+    this.metrics = metrics;
+    this.fanOutExecutor = fanOutExecutor;
+    this.options = Sets.immutableEnumSet(options);
   }
 
+  /**
+   * See {@link ChangeData#setLazyLoad(boolean)}. If lazyLoad is set, converting data from
+   * index-backed {@link ChangeData} will fail with an exception.
+   */
   public ChangeJson lazyLoad(boolean load) {
     lazyLoad = load;
     return this;
@@ -360,20 +416,21 @@
 
   public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
       throws OrmException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(FluentIterable.from(in).transformAndConcat(QueryResult::entities));
-
-    List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
-    Map<Change.Id, ChangeInfo> out = new HashMap<>();
-    for (QueryResult<ChangeData> r : in) {
-      List<ChangeInfo> infos = toChangeInfo(out, r.entities());
-      if (!infos.isEmpty() && r.more()) {
-        infos.get(infos.size() - 1)._moreChanges = true;
+    try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
+      accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+      List<List<ChangeInfo>> res = new ArrayList<>(in.size());
+      Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      for (QueryResult<ChangeData> r : in) {
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+        infos.forEach(c -> cache.put(new Change.Id(c._number), c));
+        if (!infos.isEmpty() && r.more()) {
+          infos.get(infos.size() - 1)._moreChanges = true;
+        }
+        res.add(infos);
       }
-      res.add(infos);
+      accountLoader.fill();
+      return res;
     }
-    accountLoader.fill();
-    return res;
   }
 
   public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
@@ -381,12 +438,29 @@
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
     for (ChangeData cd : in) {
-      out.add(format(cd));
+      out.add(format(cd, Optional.empty(), false));
     }
     accountLoader.fill();
     return out;
   }
 
+  private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
+    Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
+    for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+      if (submitRecord.requirements == null) {
+        continue;
+      }
+      for (SubmitRequirement requirement : submitRecord.requirements) {
+        reqInfos.add(requirementToInfo(requirement, submitRecord.status));
+      }
+    }
+    return reqInfos;
+  }
+
+  private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
+    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type(), req.data());
+  }
+
   private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
     if (lazyLoad) {
       ChangeData.ensureChangeLoaded(all);
@@ -410,37 +484,54 @@
     return options.contains(option);
   }
 
-  private List<ChangeInfo> toChangeInfo(Map<Change.Id, ChangeInfo> out, List<ChangeData> changes) {
-    List<ChangeInfo> info = Lists.newArrayListWithCapacity(changes.size());
-    for (ChangeData cd : changes) {
-      ChangeInfo i = out.get(cd.getId());
-      if (i == null) {
-        try {
-          i = toChangeInfo(cd, Optional.empty());
-        } catch (PatchListNotAvailableException
-            | GpgException
-            | OrmException
-            | IOException
-            | PermissionBackendException
-            | RuntimeException e) {
-          if (has(CHECK)) {
-            i = checkOnly(cd);
-          } else if (e instanceof NoSuchChangeException) {
-            log.info(
-                "NoSuchChangeException: Omitting corrupt change "
-                    + cd.getId()
-                    + " from results. Seems to be stale in the index.");
-            continue;
-          } else {
-            log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
-            continue;
+  private List<ChangeInfo> toChangeInfos(
+      List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
+    try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
+      // Create a list of formatting calls that can be called sequentially or in parallel
+      List<Callable<Optional<ChangeInfo>>> formattingCalls = new ArrayList<>(changes.size());
+      for (ChangeData cd : changes) {
+        formattingCalls.add(
+            () -> {
+              ChangeInfo i = cache.get(cd.getId());
+              if (i != null) {
+                return Optional.of(i);
+              }
+              try {
+                ensureLoaded(Collections.singleton(cd));
+                return Optional.of(format(cd, Optional.empty(), false));
+              } catch (OrmException | RuntimeException e) {
+                log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
+                return Optional.empty();
+              }
+            });
+      }
+
+      long numProjects = changes.stream().map(c -> c.project()).distinct().count();
+      if (!lazyLoad || changes.size() < 3 || numProjects < 2) {
+        // Format these changes in the request thread as the multithreading overhead would be too
+        // high.
+        List<ChangeInfo> result = new ArrayList<>(changes.size());
+        for (Callable<Optional<ChangeInfo>> c : formattingCalls) {
+          try {
+            c.call().ifPresent(result::add);
+          } catch (Exception e) {
+            log.warn("Omitting change due to exception", e);
           }
         }
-        out.put(cd.getId(), i);
+        return result;
       }
-      info.add(i);
+
+      // Format the changes in parallel on the executor
+      List<ChangeInfo> result = new ArrayList<>(changes.size());
+      try {
+        for (Future<Optional<ChangeInfo>> f : fanOutExecutor.invokeAll(formattingCalls)) {
+          f.get().ifPresent(result::add);
+        }
+      } catch (InterruptedException | ExecutionException e) {
+        throw new IllegalStateException(e);
+      }
+      return result;
     }
-    return info;
   }
 
   private ChangeInfo checkOnly(ChangeData cd) {
@@ -489,6 +580,14 @@
   private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
       throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
           IOException {
+    try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
+      return toChangeInfoImpl(cd, limitToPsId);
+    }
+  }
+
+  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+      throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
+          IOException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
 
@@ -503,7 +602,6 @@
       }
     }
 
-    PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
@@ -555,13 +653,15 @@
       out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
     }
 
-    out.labels = labelsFor(perm, cd, has(LABELS), has(DETAILED_LABELS));
+    out.labels = labelsFor(cd, has(LABELS), has(DETAILED_LABELS));
+    out.requirements = requirementsFor(cd);
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
       // 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)
@@ -664,8 +764,7 @@
     return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
   }
 
-  private Map<String, LabelInfo> labelsFor(
-      PermissionBackend.ForChange perm, ChangeData cd, boolean standard, boolean detailed)
+  private Map<String, LabelInfo> labelsFor(ChangeData cd, boolean standard, boolean detailed)
       throws OrmException, PermissionBackendException {
     if (!standard && !detailed) {
       return null;
@@ -674,21 +773,17 @@
     LabelTypes labelTypes = cd.getLabelTypes();
     Map<String, LabelWithStatus> withStatus =
         cd.change().getStatus() == Change.Status.MERGED
-            ? labelsForSubmittedChange(perm, cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(perm, cd, labelTypes, standard, detailed);
+            ? labelsForSubmittedChange(cd, labelTypes, standard, detailed)
+            : labelsForUnsubmittedChange(cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
   private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
-      PermissionBackend.ForChange perm,
-      ChangeData cd,
-      LabelTypes labelTypes,
-      boolean standard,
-      boolean detailed)
+      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
       throws OrmException, PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
     if (detailed) {
-      setAllApprovals(perm, cd, labels);
+      setAllApprovals(cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       LabelType type = labelTypes.byLabel(e.getKey());
@@ -773,8 +868,7 @@
     }
   }
 
-  private void setAllApprovals(
-      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
+  private void setAllApprovals(ChangeData cd, Map<String, LabelWithStatus> labels)
       throws OrmException, PermissionBackendException {
     Change.Status status = cd.change().getStatus();
     checkState(
@@ -797,7 +891,7 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = labelTypes.byLabel(e.getKey());
@@ -880,11 +974,7 @@
   }
 
   private Map<String, LabelWithStatus> labelsForSubmittedChange(
-      PermissionBackend.ForChange basePerm,
-      ChangeData cd,
-      LabelTypes labelTypes,
-      boolean standard,
-      boolean detailed)
+      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
       throws OrmException, PermissionBackendException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
@@ -943,7 +1033,7 @@
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
-        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+        PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
         pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
           ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
@@ -1223,7 +1313,6 @@
       throws PatchListNotAvailableException, GpgException, OrmException, IOException,
           PermissionBackendException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    Boolean isWorldReadable = null;
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
@@ -1237,12 +1326,7 @@
           want = id.equals(cd.change().currentPatchSetId());
         }
         if (want) {
-          if (isWorldReadable == null) {
-            isWorldReadable = isWorldReadable(cd);
-          }
-          res.put(
-              in.getRevision().get(),
-              toRevisionInfo(cd, in, repo, rw, false, changeInfo, isWorldReadable));
+          res.put(in.getRevision().get(), toRevisionInfo(cd, in, repo, rw, false, changeInfo));
         }
       }
       return res;
@@ -1282,7 +1366,7 @@
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
-      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null, isWorldReadable(cd));
+      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null);
       accountLoader.fill();
       return rev;
     }
@@ -1294,9 +1378,9 @@
       @Nullable Repository repo,
       @Nullable RevWalk rw,
       boolean fillCommit,
-      @Nullable ChangeInfo changeInfo,
-      boolean isWorldReadable)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      @Nullable ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
     out.isCurrent = in.getId().equals(c.currentPatchSetId());
@@ -1304,7 +1388,7 @@
     out.ref = in.getRefName();
     out.created = in.getCreatedOn();
     out.uploader = accountLoader.get(in.getUploader());
-    out.fetch = makeFetchMap(cd, in, isWorldReadable);
+    out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.getDescription();
 
@@ -1394,7 +1478,8 @@
     return info;
   }
 
-  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in, boolean isWorldReadable) {
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
+      throws PermissionBackendException, OrmException, IOException {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
@@ -1403,7 +1488,7 @@
           || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
         continue;
       }
-      if (!scheme.isAuthSupported() && !isWorldReadable) {
+      if (!scheme.isAuthSupported() && !isWorldReadable(cd)) {
         continue;
       }
 
@@ -1457,14 +1542,23 @@
     label.all.add(approval);
   }
 
+  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd)
+      throws OrmException {
+    return permissionBackendForChange(permissionBackend.user(user).database(db), cd);
+  }
+
+  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd)
+      throws OrmException {
+    return permissionBackendForChange(permissionBackend.absentUser(user).database(db), cd);
+  }
+
   /**
    * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
    *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
    *     lazyload}.
    */
-  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd)
-      throws OrmException {
-    PermissionBackend.WithUser withUser = permissionBackend.user(user).database(db);
+  private PermissionBackend.ForChange permissionBackendForChange(
+      PermissionBackend.WithUser withUser, ChangeData cd) throws OrmException {
     return lazyLoad
         ? withUser.change(cd)
         : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 05f8fc0..6286a2f 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.SendEmailExecutor;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index ff5fb0b..00b7a88 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -34,7 +34,7 @@
 import eu.medsea.mimeutil.MimeType;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.Random;
+import java.security.SecureRandom;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -42,7 +42,6 @@
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -57,7 +56,7 @@
   private static final String X_GIT_GITLINK = "x-git/gitlink";
   private static final int MAX_SIZE = 5 << 20;
   private static final String ZIP_TYPE = "application/zip";
-  private static final Random rng = new Random();
+  private static final SecureRandom rng = new SecureRandom();
 
   private final GitRepositoryManager repoManager;
   private final FileTypeRegistry registry;
@@ -104,35 +103,35 @@
       throws IOException, ResourceNotFoundException {
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
-      ObjectReader reader = rw.getObjectReader();
-      TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
-      if (tw == null) {
-        throw new ResourceNotFoundException();
-      }
+      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
+        if (tw == null) {
+          throw new ResourceNotFoundException();
+        }
 
-      org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
-      ObjectId id = tw.getObjectId(0);
-      if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
-        return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
-      }
+        org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
+        ObjectId id = tw.getObjectId(0);
+        if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
+          return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
+        }
 
-      ObjectLoader obj = repo.open(id, OBJ_BLOB);
-      byte[] raw;
-      try {
-        raw = obj.getCachedBytes(MAX_SIZE);
-      } catch (LargeObjectException e) {
-        raw = null;
-      }
+        ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        byte[] raw;
+        try {
+          raw = obj.getCachedBytes(MAX_SIZE);
+        } catch (LargeObjectException e) {
+          raw = null;
+        }
 
-      String type;
-      if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
-        type = X_GIT_SYMLINK;
-      } else {
-        type = registry.getMimeType(path, raw).toString();
-        type = resolveContentType(project, path, FileMode.FILE, type);
-      }
+        String type;
+        if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
+          type = X_GIT_SYMLINK;
+        } else {
+          type = registry.getMimeType(path, raw).toString();
+          type = resolveContentType(project, path, FileMode.FILE, type);
+        }
 
-      return asBinaryResult(raw, obj).setContentType(type).base64();
+        return asBinaryResult(raw, obj).setContentType(type).base64();
+      }
     }
   }
 
@@ -166,30 +165,30 @@
         }
         commit = rw.parseCommit(commit.getParent(parent - 1));
       }
-      ObjectReader reader = rw.getObjectReader();
-      TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
-      if (tw == null) {
-        throw new ResourceNotFoundException();
-      }
+      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
+        if (tw == null) {
+          throw new ResourceNotFoundException();
+        }
 
-      int mode = tw.getFileMode(0).getObjectType();
-      if (mode != Constants.OBJ_BLOB) {
-        throw new ResourceNotFoundException();
-      }
+        int mode = tw.getFileMode(0).getObjectType();
+        if (mode != Constants.OBJ_BLOB) {
+          throw new ResourceNotFoundException();
+        }
 
-      ObjectId id = tw.getObjectId(0);
-      ObjectLoader obj = repo.open(id, OBJ_BLOB);
-      byte[] raw;
-      try {
-        raw = obj.getCachedBytes(MAX_SIZE);
-      } catch (LargeObjectException e) {
-        raw = null;
-      }
+        ObjectId id = tw.getObjectId(0);
+        ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        byte[] raw;
+        try {
+          raw = obj.getCachedBytes(MAX_SIZE);
+        } catch (LargeObjectException e) {
+          raw = null;
+        }
 
-      MimeType contentType = registry.getMimeType(path, raw);
-      return registry.isSafeInline(contentType)
-          ? wrapBlob(path, obj, raw, contentType, suffix)
-          : zipBlob(path, obj, commit, suffix);
+        MimeType contentType = registry.getMimeType(path, raw);
+        return registry.isSafeInline(contentType)
+            ? wrapBlob(path, obj, raw, contentType, suffix)
+            : zipBlob(path, obj, commit, suffix);
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index 88bf893..658c91c 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -146,7 +146,7 @@
    *   <li>after = commits with time >= target.getCommitTime()
    * </ul>
    *
-   * Each of the before/after lists is sorted by the the commit time.
+   * Each of the before/after lists is sorted by the commit time.
    *
    * @param before
    * @param after
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 3ddfc63..deb5022 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -34,19 +34,29 @@
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<RestView<RevisionResource>>() {};
 
+  public static RevisionResource createNonCachable(ChangeResource change, PatchSet ps) {
+    return new RevisionResource(change, ps, Optional.empty(), false);
+  }
+
   private final ChangeResource change;
   private final PatchSet ps;
   private final Optional<ChangeEdit> edit;
-  private boolean cacheable = true;
+  private final boolean cacheable;
 
   public RevisionResource(ChangeResource change, PatchSet ps) {
     this(change, ps, Optional.empty());
   }
 
   public RevisionResource(ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit) {
+    this(change, ps, edit, true);
+  }
+
+  private RevisionResource(
+      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cachable) {
     this.change = change;
     this.ps = ps;
     this.edit = edit;
+    this.cacheable = cachable;
   }
 
   public boolean isCacheable() {
@@ -98,12 +108,6 @@
     return getChangeResource().getUser();
   }
 
-  public RevisionResource doNotCache() {
-    // TODO(hanwen): return a copy so cacheable can be final.
-    cacheable = false;
-    return this;
-  }
-
   public Optional<ChangeEdit> getEdit() {
     return edit;
   }
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 5025892..961dbbd 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -43,4 +43,5 @@
   public String viewConnections;
   public String viewPlugins;
   public String viewQueue;
+  public String viewAccess;
 }
diff --git a/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java b/java/com/google/gerrit/server/config/ChangeUpdateExecutor.java
similarity index 91%
rename from java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
rename to java/com/google/gerrit/server/config/ChangeUpdateExecutor.java
index 1d957cf..4c9e5f0 100644
--- a/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
+++ b/java/com/google/gerrit/server/config/ChangeUpdateExecutor.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.update;
+package com.google.gerrit.server.config;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
diff --git a/java/com/google/gerrit/server/config/ConfigKey.java b/java/com/google/gerrit/server/config/ConfigKey.java
new file mode 100644
index 0000000..aa4ffb0
--- /dev/null
+++ b/java/com/google/gerrit/server/config/ConfigKey.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.server.config;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+@AutoValue
+public abstract class ConfigKey {
+  public abstract String section();
+
+  @Nullable
+  public abstract String subsection();
+
+  public abstract String name();
+
+  public static ConfigKey create(String section, String subsection, String name) {
+    return new AutoValue_ConfigKey(section, subsection, name);
+  }
+
+  public static ConfigKey create(String section, String name) {
+    return new AutoValue_ConfigKey(section, null, name);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(section()).append(".");
+    if (subsection() != null) {
+      sb.append(subsection()).append(".");
+    }
+    sb.append(name());
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
new file mode 100644
index 0000000..9bd4533
--- /dev/null
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -0,0 +1,214 @@
+// 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.config;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * This event is produced by {@link GerritServerConfigReloader} and forwarded to callers
+ * implementing {@link GerritConfigListener}.
+ *
+ * <p>The event intends to:
+ *
+ * <p>1. Help the callers figure out if any action should be taken, depending on which entries are
+ * updated in gerrit.config.
+ *
+ * <p>2. Provide the callers with a mechanism to accept/reject the entries of interest: @see
+ * accept(Set<ConfigKey> entries), @see accept(String section), @see reject(Set<ConfigKey> entries)
+ * (+ various overloaded versions of these)
+ */
+public class ConfigUpdatedEvent {
+  private final Config oldConfig;
+  private final Config newConfig;
+
+  public ConfigUpdatedEvent(Config oldConfig, Config newConfig) {
+    this.oldConfig = oldConfig;
+    this.newConfig = newConfig;
+  }
+
+  public Config getOldConfig() {
+    return this.oldConfig;
+  }
+
+  public Config getNewConfig() {
+    return this.newConfig;
+  }
+
+  public Update accept(ConfigKey entry) {
+    return accept(Collections.singleton(entry));
+  }
+
+  public Update accept(Set<ConfigKey> entries) {
+    return createUpdate(entries, UpdateResult.APPLIED);
+  }
+
+  public Update accept(String section) {
+    Set<ConfigKey> entries = getEntriesFromSection(oldConfig, section);
+    entries.addAll(getEntriesFromSection(newConfig, section));
+    return createUpdate(entries, UpdateResult.APPLIED);
+  }
+
+  public Update reject(Set<ConfigKey> entries) {
+    return createUpdate(entries, UpdateResult.REJECTED);
+  }
+
+  private static Set<ConfigKey> getEntriesFromSection(Config config, String section) {
+    Set<ConfigKey> res = new LinkedHashSet<>();
+    for (String name : config.getNames(section, true)) {
+      res.add(ConfigKey.create(section, name));
+    }
+    for (String sub : config.getSubsections(section)) {
+      for (String name : config.getNames(section, sub, true)) {
+        res.add(ConfigKey.create(section, sub, name));
+      }
+    }
+    return res;
+  }
+
+  private Update createUpdate(Set<ConfigKey> entries, UpdateResult updateResult) {
+    Update update = new Update(updateResult);
+    entries
+        .stream()
+        .filter(this::isValueUpdated)
+        .forEach(
+            key -> {
+              update.addConfigUpdate(
+                  new ConfigUpdateEntry(
+                      key,
+                      oldConfig.getString(key.section(), key.subsection(), key.name()),
+                      newConfig.getString(key.section(), key.subsection(), key.name())));
+            });
+    return update;
+  }
+
+  public boolean isSectionUpdated(String section) {
+    Set<ConfigKey> entries = getEntriesFromSection(oldConfig, section);
+    entries.addAll(getEntriesFromSection(newConfig, section));
+    return isEntriesUpdated(entries);
+  }
+
+  public boolean isValueUpdated(String section, String subsection, String name) {
+    return !Objects.equals(
+        oldConfig.getString(section, subsection, name),
+        newConfig.getString(section, subsection, name));
+  }
+
+  public boolean isValueUpdated(ConfigKey key) {
+    return isValueUpdated(key.section(), key.subsection(), key.name());
+  }
+
+  public boolean isValueUpdated(String section, String name) {
+    return isValueUpdated(section, null, name);
+  }
+
+  public boolean isEntriesUpdated(Set<ConfigKey> entries) {
+    for (ConfigKey entry : entries) {
+      if (isValueUpdated(entry.section(), entry.subsection(), entry.name())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public enum UpdateResult {
+    APPLIED,
+    REJECTED;
+
+    @Override
+    public String toString() {
+      return StringUtils.capitalize(name().toLowerCase());
+    }
+  }
+
+  /**
+   * One Accepted/Rejected Update have one or more config updates (ConfigUpdateEntry) tied to it.
+   */
+  public static class Update {
+    private UpdateResult result;
+    private final Set<ConfigUpdateEntry> configUpdates;
+
+    public Update(UpdateResult result) {
+      this.configUpdates = new LinkedHashSet<>();
+      this.result = result;
+    }
+
+    public UpdateResult getResult() {
+      return result;
+    }
+
+    public List<ConfigUpdateEntry> getConfigUpdates() {
+      return ImmutableList.copyOf(configUpdates);
+    }
+
+    public void addConfigUpdate(ConfigUpdateEntry entry) {
+      this.configUpdates.add(entry);
+    }
+  }
+
+  public enum ConfigEntryType {
+    ADDED,
+    REMOVED,
+    MODIFIED,
+    UNMODIFIED
+  }
+
+  public static class ConfigUpdateEntry {
+    public final ConfigKey key;
+    public final String oldVal;
+    public final String newVal;
+
+    public ConfigUpdateEntry(ConfigKey key, String oldVal, String newVal) {
+      this.key = key;
+      this.oldVal = oldVal;
+      this.newVal = newVal;
+    }
+
+    /** Note: The toString() is used to format the output from @see ReloadConfig. */
+    @Override
+    public String toString() {
+      switch (getUpdateType()) {
+        case ADDED:
+          return String.format("+ %s = %s", key, newVal);
+        case MODIFIED:
+          return String.format("* %s = [%s => %s]", key, oldVal, newVal);
+        case REMOVED:
+          return String.format("- %s = %s", key, oldVal);
+        case UNMODIFIED:
+          return String.format("  %s = %s", key, newVal);
+        default:
+          throw new IllegalStateException("Unexpected UpdateType: " + getUpdateType().name());
+      }
+    }
+
+    public ConfigEntryType getUpdateType() {
+      if (oldVal == null && newVal != null) {
+        return ConfigEntryType.ADDED;
+      }
+      if (oldVal != null && newVal == null) {
+        return ConfigEntryType.REMOVED;
+      }
+      if (Objects.equals(oldVal, newVal)) {
+        return ConfigEntryType.UNMODIFIED;
+      }
+      return ConfigEntryType.MODIFIED;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritConfigListener.java b/java/com/google/gerrit/server/config/GerritConfigListener.java
new file mode 100644
index 0000000..337a962
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritConfigListener.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.config;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.EventListener;
+import java.util.List;
+
+/**
+ * Implementations of the GerritConfigListener interface expects to react GerritServerConfig
+ * updates. @see ConfigUpdatedEvent.
+ */
+@ExtensionPoint
+public interface GerritConfigListener extends EventListener {
+  List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event);
+}
diff --git a/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java b/java/com/google/gerrit/server/config/GerritConfigListenerHelper.java
new file mode 100644
index 0000000..1dfa3fc
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritConfigListenerHelper.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.config;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Collections;
+
+public class GerritConfigListenerHelper {
+  public static GerritConfigListener acceptIfChanged(ConfigKey... keys) {
+    return e ->
+        e.isEntriesUpdated(ImmutableSet.copyOf(keys))
+            ? Collections.singletonList(e.accept(ImmutableSet.copyOf(keys)))
+            : Collections.emptyList();
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 1084a49..5da5d1a 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -169,6 +169,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.config.ConfigRestModule;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -284,6 +285,8 @@
     bind(TransferConfig.class);
 
     bind(GcConfig.class);
+    DynamicSet.setOf(binder(), GerritConfigListener.class);
+
     bind(ChangeCleanupConfig.class);
     bind(AccountDeactivator.class);
 
@@ -370,6 +373,8 @@
     DynamicMap.mapOf(binder(), DownloadCommand.class);
     DynamicMap.mapOf(binder(), CloneCommand.class);
     DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
+    DynamicSet.bind(binder(), GerritConfigListener.class)
+        .toInstance(SuggestReviewers.configListener());
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
@@ -422,9 +427,8 @@
 
     bind(AccountManager.class);
 
-    bind(new TypeLiteral<List<CommentLinkInfo>>() {})
-        .toProvider(CommentLinkProvider.class)
-        .in(SINGLETON);
+    bind(new TypeLiteral<List<CommentLinkInfo>>() {}).toProvider(CommentLinkProvider.class);
+    DynamicSet.bind(binder(), GerritConfigListener.class).to(CommentLinkProvider.class);
 
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index a93d1f2..25ee759 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -76,8 +76,7 @@
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
     bind(Config.class)
         .annotatedWith(GerritServerConfig.class)
-        .toProvider(GerritServerConfigProvider.class)
-        .in(SINGLETON);
+        .toProvider(GerritServerConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigProvider.java b/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
index 82fb6ec..e02bf1c 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -36,25 +37,45 @@
 /**
  * Provides {@link Config} annotated with {@link GerritServerConfig}.
  *
- * <p>Note that this class is not a singleton, so the few callers that need a reloaded-on-demand
- * config can inject a {@code GerritServerConfigProvider}. However, most callers won't need this,
- * and will just inject {@code @GerritServerConfig Config} directly, which is bound as a singleton
- * in {@link GerritServerConfigModule}.
+ * <p>To react on config updates, the caller should implement @see GerritConfigListener.
+ *
+ * <p>The few callers that need a reloaded-on-demand config can inject a {@code
+ * GerritServerConfigProvider} and request the lastest config with fetchLatestConfig().
  */
+@Singleton
 public class GerritServerConfigProvider implements Provider<Config> {
   private static final Logger log = LoggerFactory.getLogger(GerritServerConfigProvider.class);
 
   private final SitePaths site;
   private final SecureStore secureStore;
 
+  private final Object lock = new Object();
+
+  private GerritConfig gerritConfig;
+
   @Inject
   GerritServerConfigProvider(SitePaths site, SecureStore secureStore) {
     this.site = site;
     this.secureStore = secureStore;
+    this.gerritConfig = loadConfig();
   }
 
   @Override
   public Config get() {
+    synchronized (lock) {
+      return gerritConfig;
+    }
+  }
+
+  protected ConfigUpdatedEvent updateConfig() {
+    synchronized (lock) {
+      Config oldConfig = gerritConfig;
+      gerritConfig = loadConfig();
+      return new ConfigUpdatedEvent(oldConfig, gerritConfig);
+    }
+  }
+
+  public GerritConfig loadConfig() {
     FileBasedConfig baseConfig = loadConfig(null, site.gerrit_config);
     if (!baseConfig.getFile().exists()) {
       log.info("No " + site.gerrit_config.toAbsolutePath() + "; assuming defaults");
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigReloader.java b/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
new file mode 100644
index 0000000..61adadd
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritServerConfigReloader.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.config;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Issues a configuration reload from the GerritServerConfigProvider and notify all listeners. */
+@Singleton
+public class GerritServerConfigReloader {
+  private static final Logger log = LoggerFactory.getLogger(GerritServerConfigReloader.class);
+
+  private final GerritServerConfigProvider configProvider;
+  private final DynamicSet<GerritConfigListener> configListeners;
+
+  @Inject
+  GerritServerConfigReloader(
+      GerritServerConfigProvider configProvider, DynamicSet<GerritConfigListener> configListeners) {
+    this.configProvider = configProvider;
+    this.configListeners = configListeners;
+  }
+
+  /**
+   * Reloads the Gerrit Server Configuration from disk. Synchronized to ensure that one issued
+   * reload is fully completed before a new one starts.
+   */
+  public List<ConfigUpdatedEvent.Update> reloadConfig() {
+    log.info("Starting server configuration reload");
+    List<ConfigUpdatedEvent.Update> updates = fireUpdatedConfigEvent(configProvider.updateConfig());
+    log.info("Server configuration reload completed succesfully");
+    return updates;
+  }
+
+  public List<ConfigUpdatedEvent.Update> fireUpdatedConfigEvent(ConfigUpdatedEvent event) {
+    ArrayList<ConfigUpdatedEvent.Update> result = new ArrayList<>();
+    for (GerritConfigListener configListener : configListeners) {
+      result.addAll(configListener.configUpdated(event));
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritServerId.java b/java/com/google/gerrit/server/config/GerritServerId.java
index 87dc44a..237f18c 100644
--- a/java/com/google/gerrit/server/config/GerritServerId.java
+++ b/java/com/google/gerrit/server/config/GerritServerId.java
@@ -22,7 +22,8 @@
 /**
  * Marker on a string holding a unique identifier for the server.
  *
- * <p>This value is generated on first use and stored in {@code $site_path/etc/uuid}.
+ * <p>This value is generated on first use and stored in {@code gerrit.serverId} in {@code
+ * gerrit.config}.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index 39ff327..4a72ffc 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -104,7 +104,7 @@
     this(displayName, defaultValue, null);
   }
 
-  //For inheritable boolean use 'LIST' type with InheritableBoolean
+  // For inheritable boolean use 'LIST' type with InheritableBoolean
   public ProjectConfigEntry(String displayName, boolean defaultValue, String description) {
     this(
         displayName,
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java b/java/com/google/gerrit/server/config/ReceiveCommitsExecutor.java
similarity index 88%
rename from java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
rename to java/com/google/gerrit/server/config/ReceiveCommitsExecutor.java
index ee83a2c..16d389c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
+++ b/java/com/google/gerrit/server/config/ReceiveCommitsExecutor.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git.receive;
+package com.google.gerrit.server.config;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
@@ -20,7 +20,7 @@
 import java.lang.annotation.Retention;
 import java.util.concurrent.ExecutorService;
 
-/** Marker on the global {@link ExecutorService} used by {@link ReceiveCommits}. */
+/** Marker on the global {@link ExecutorService} used by {@code ReceiveCommits}. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface ReceiveCommitsExecutor {}
diff --git a/java/com/google/gerrit/server/mail/SendEmailExecutor.java b/java/com/google/gerrit/server/config/SendEmailExecutor.java
similarity index 95%
rename from java/com/google/gerrit/server/mail/SendEmailExecutor.java
rename to java/com/google/gerrit/server/config/SendEmailExecutor.java
index bfd5c17..cf90cbf 100644
--- a/java/com/google/gerrit/server/mail/SendEmailExecutor.java
+++ b/java/com/google/gerrit/server/config/SendEmailExecutor.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.server.config;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
similarity index 78%
rename from java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
rename to java/com/google/gerrit/server/config/SysExecutorModule.java
index 40c0aa5..b9fe34c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -12,15 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git.receive;
+package com.google.gerrit.server.config;
 
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.mail.SendEmailExecutor;
-import com.google.gerrit.server.update.ChangeUpdateExecutor;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -33,10 +31,11 @@
 /**
  * Module providing the {@link ReceiveCommitsExecutor}.
  *
- * <p>Unlike {@link ReceiveCommitsModule}, this module is intended to be installed only in top-level
- * injectors like in {@code Daemon}, not in the {@code sysInjector}.
+ * <p>This module is intended to be installed at the top level when creating a {@code sysInjector}
+ * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
+ * swapped out for the googlesource.com implementation.
  */
-public class ReceiveCommitsExecutorModule extends AbstractModule {
+public class SysExecutorModule extends AbstractModule {
   @Override
   protected void configure() {}
 
@@ -65,6 +64,17 @@
 
   @Provides
   @Singleton
+  @FanOutExecutor
+  public ExecutorService createFanOutExecutor(@GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("execution", null, "fanOutThreadPoolSize", 25);
+    if (poolSize == 0) {
+      return MoreExecutors.newDirectExecutorService();
+    }
+    return queues.createQueue(poolSize, "FanOut");
+  }
+
+  @Provides
+  @Singleton
   @ChangeUpdateExecutor
   public ListeningExecutorService createChangeUpdateExecutor(@GerritServerConfig Config config) {
     int poolSize = config.getInt("receive", null, "changeUpdateThreads", 1);
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index 8a0ea51..3203024 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.data;
 
+import java.util.Map;
+
 /**
  * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
  * Gerrit internal classes, to be serialized
  */
 public class SubmitRequirementAttribute {
-  public String shortReason;
-  public String fullReason;
-  public String label;
+  public Map<String, String> data;
+  public String type;
+  public String fallbackText;
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index d3d52e4..e557250 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -261,9 +261,9 @@
       sa.requirements = new ArrayList<>();
       for (SubmitRequirement req : submitRecord.requirements) {
         SubmitRequirementAttribute re = new SubmitRequirementAttribute();
-        re.shortReason = req.shortReason();
-        re.fullReason = req.fullReason();
-        re.label = req.label().orElse(null);
+        re.fallbackText = req.fallbackText();
+        re.type = req.type();
+        re.data = req.data();
         sa.requirements.add(re);
       }
     }
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 2503ffc..61533da 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -159,7 +159,7 @@
     }
   }
 
-  private static class Event implements GitReferenceUpdatedListener.Event {
+  public static class Event implements GitReferenceUpdatedListener.Event {
     private final String projectName;
     private final String ref;
     private final String oldObjectId;
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 2439bb7..bb50218 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.mail.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 59fd58b2..da1f1ac 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -481,10 +481,11 @@
       return new byte[] {};
     }
 
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
-      return obj.getCachedBytes(Integer.MAX_VALUE);
+    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+      if (tw != null) {
+        ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
+        return obj.getCachedBytes(Integer.MAX_VALUE);
+      }
     }
     return new byte[] {};
   }
@@ -495,9 +496,10 @@
       return null;
     }
 
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      return tw.getObjectId(0);
+    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+      if (tw != null) {
+        return tw.getObjectId(0);
+      }
     }
 
     return null;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 23e771d..b7297fa 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ReceiveCommitsExecutor;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.ProjectRunnable;
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 52b2372..4d24b4b 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -13,6 +13,7 @@
         "//lib:guava",
         "//lib:gwtorm",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 88fd40b..17e804f 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -19,6 +19,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 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;
@@ -35,7 +36,6 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
 import com.google.common.base.Function;
@@ -497,7 +497,7 @@
 
     // Collections populated during processing.
     actualCommands = new ArrayList<>();
-    errors = LinkedListMultimap.create();
+    errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
     messages = new ArrayList<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
@@ -1179,6 +1179,11 @@
   }
 
   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();
@@ -1222,8 +1227,7 @@
       }
       actualCommands.add(cmd);
     } else {
-      cmd.setResult(
-          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+      cmd.setResult(REJECTED_OTHER_REASON, "need '" + PermissionRule.FORCE_PUSH + "' privilege.");
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index d842790..3b8091c 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -43,12 +43,12 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index bde140b..134de78 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -5,6 +5,7 @@
     testonly = 1,
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:truth",
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
new file mode 100644
index 0000000..c0efe2b
--- /dev/null
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.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.group.testing;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Implementation of GroupBackend for tests. */
+public class TestGroupBackend implements GroupBackend {
+  private static final String PREFIX = "testbackend:";
+
+  private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
+
+  /**
+   * Create a group by name.
+   *
+   * @param name the group name, optionally prefixed by "testbackend:".
+   * @return the created group
+   */
+  public GroupDescription.Basic create(String name) {
+    checkNotNull(name);
+    return create(new AccountGroup.UUID(name.startsWith(PREFIX) ? name : PREFIX + name));
+  }
+
+  /**
+   * Create a group by UUID.
+   *
+   * @param uuid the group UUID to add.
+   * @return the created group
+   */
+  public GroupDescription.Basic create(AccountGroup.UUID uuid) {
+    checkState(uuid.get().startsWith(PREFIX), "test group UUID must have prefix '" + PREFIX + "'");
+    if (groups.containsKey(uuid)) {
+      return groups.get(uuid);
+    }
+    GroupDescription.Basic group =
+        new GroupDescription.Basic() {
+          @Override
+          public AccountGroup.UUID getGroupUUID() {
+            return uuid;
+          }
+
+          @Override
+          public String getName() {
+            return uuid.get().substring(PREFIX.length());
+          }
+
+          @Override
+          public String getEmailAddress() {
+            return null;
+          }
+
+          @Override
+          public String getUrl() {
+            return null;
+          }
+        };
+    groups.put(uuid, group);
+    return group;
+  }
+
+  /**
+   * Remove a group. No-op if the group does not exist.
+   *
+   * @param uuid the group.
+   */
+  public void remove(AccountGroup.UUID uuid) {
+    groups.remove(uuid);
+  }
+
+  @Override
+  public boolean handles(AccountGroup.UUID uuid) {
+    if (uuid != null) {
+      String id = uuid.get();
+      return id != null && id.startsWith(PREFIX);
+    }
+    return false;
+  }
+
+  @Override
+  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+    return uuid == null ? null : groups.get(uuid);
+  }
+
+  @Override
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
+    return ImmutableList.of();
+  }
+
+  @Override
+  public GroupMembership membershipsOf(IdentifiedUser user) {
+    return GroupMembership.EMPTY;
+  }
+
+  @Override
+  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
new file mode 100644
index 0000000..12aedfd
--- /dev/null
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -0,0 +1,121 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public abstract class AbstractIndexModule extends AbstractModule {
+
+  private final int threads;
+  private final Map<String, Integer> singleVersions;
+  private final boolean onlineUpgrade;
+  private final boolean slave;
+
+  protected AbstractIndexModule(
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
+    if (singleVersions != null) {
+      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
+    }
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+    this.onlineUpgrade = onlineUpgrade;
+    this.slave = slave;
+  }
+
+  @Override
+  protected void configure() {
+    if (slave) {
+      bind(AccountIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+      bind(ChangeIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+      bind(ProjectIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+    } else {
+      install(
+          new FactoryModuleBuilder()
+              .implement(AccountIndex.class, getAccountIndex())
+              .build(AccountIndex.Factory.class));
+      install(
+          new FactoryModuleBuilder()
+              .implement(ChangeIndex.class, getChangeIndex())
+              .build(ChangeIndex.Factory.class));
+      install(
+          new FactoryModuleBuilder()
+              .implement(ProjectIndex.class, getProjectIndex())
+              .build(ProjectIndex.Factory.class));
+    }
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, getGroupIndex())
+            .build(GroupIndex.Factory.class));
+
+    install(new IndexModule(threads, slave));
+    if (singleVersions == null) {
+      install(new MultiVersionModule());
+    } else {
+      install(new SingleVersionModule(singleVersions));
+    }
+  }
+
+  @SuppressWarnings("unused")
+  private static <T> T createDummyIndexFactory(Schema<?> schema) {
+    throw new UnsupportedOperationException();
+  }
+
+  protected abstract Class<? extends AccountIndex> getAccountIndex();
+
+  protected abstract Class<? extends ChangeIndex> getChangeIndex();
+
+  protected abstract Class<? extends GroupIndex> getGroupIndex();
+
+  protected abstract Class<? extends ProjectIndex> getProjectIndex();
+
+  protected abstract Class<? extends VersionManager> getVersionManager();
+
+  @Provides
+  @Singleton
+  IndexConfig provideIndexConfig(@GerritServerConfig Config cfg) {
+    return getIndexConfig(cfg);
+  }
+
+  protected IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
+  }
+
+  private class MultiVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      Class<? extends VersionManager> versionManagerClass = getVersionManager();
+      bind(VersionManager.class).to(versionManagerClass);
+      listener().to(versionManagerClass);
+      if (onlineUpgrade) {
+        listener().to(OnlineUpgrader.class);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index 620dd36..8aabb60 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -137,6 +137,16 @@
     return false;
   }
 
+  /**
+   * Tells if an index with this name is currently known or not.
+   *
+   * @param name index name
+   * @return true if index is known and can be used, otherwise false.
+   */
+  public boolean isKnownIndex(String name) {
+    return defs.get(name) != null;
+  }
+
   protected <K, V, I extends Index<K, V>> void initIndex(
       IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
     TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 6dbad6a..2f8c785 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -36,7 +36,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Table;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.index.FieldDef;
@@ -77,6 +76,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -657,9 +657,9 @@
     }
 
     static class StoredRequirement {
-      String shortReason;
-      String fullReason;
-      @Nullable String label;
+      String fallbackText;
+      String type;
+      Map<String, String> data;
     }
 
     SubmitRecord.Status status;
@@ -684,9 +684,9 @@
         this.requirements = new ArrayList<>(rec.requirements.size());
         for (SubmitRequirement requirement : rec.requirements) {
           StoredRequirement sr = new StoredRequirement();
-          sr.shortReason = requirement.shortReason();
-          sr.fullReason = requirement.fullReason();
-          sr.label = requirement.label().orElse(null);
+          sr.type = requirement.type();
+          sr.fallbackText = requirement.fallbackText();
+          sr.data = requirement.data();
           this.requirements.add(sr);
         }
       }
@@ -708,10 +708,13 @@
       }
       if (requirements != null) {
         rec.requirements = new ArrayList<>(requirements.size());
-        for (StoredRequirement requirement : requirements) {
+        for (StoredRequirement req : requirements) {
           SubmitRequirement sr =
-              new SubmitRequirement(
-                  requirement.shortReason, requirement.fullReason, requirement.label);
+              SubmitRequirement.builder()
+                  .setType(req.type)
+                  .setFallbackText(req.fallbackText)
+                  .setData(req.data)
+                  .build();
           rec.requirements.add(sr);
         }
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 824fd4f..976813f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.closed;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.collect.Lists;
@@ -139,7 +140,7 @@
       throws QueryParseException {
     Predicate<ChangeData> s = rewriteImpl(in, opts);
     if (!(s instanceof ChangeDataSource)) {
-      in = Predicate.and(open(), in);
+      in = Predicate.and(Predicate.or(open(), closed()), in);
       s = rewriteImpl(in, opts);
     }
     if (!(s instanceof ChangeDataSource)) {
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 3622cf9..ae8ac31 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -127,7 +127,7 @@
   }
 
   public String getSshKey() {
-    return (sshKey != null) ? sshKey.getSshPublicKey() + "\n" : null;
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
   public String getGpgKeys() {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 29fc04f..6c05ecc 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -405,12 +405,12 @@
   }
 
   @Override
-  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
+  protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return projectState.statePermitsRead()
         && args.permissionBackend
-            .user(args.identifiedUserFactory.create(to))
+            .absentUser(to)
             .change(changeData)
-            .database(args.db.get())
+            .database(args.db)
             .test(ChangePermission.READ);
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 0f3bcdb..f1f1778 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -470,18 +469,17 @@
         rcptTo.add(to);
         add(rt, toAddress(to), override);
       }
-    } catch (OrmException | PermissionBackendException e) {
+    } catch (PermissionBackendException e) {
       log.error("Error reading database for account: " + to, e);
     }
   }
 
   /**
    * @param to account.
-   * @throws OrmException
    * @throws PermissionBackendException
    * @return whether this email is visible to the given account.
    */
-  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
+  protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index a8289c4..b08e594 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -200,30 +200,31 @@
           }
         }
 
-        Writer messageDataWriter = client.sendMessageData();
-        if (messageDataWriter == null) {
-          /* Include rejected recipient error messages here to not lose that
-           * information. That piece of the puzzle is vital if zero recipients
-           * are accepted and the server consequently rejects the DATA command.
-           */
-          throw new EmailException(
-              rejected
-                  + "Server "
-                  + smtpHost
-                  + " rejected DATA command: "
-                  + client.getReplyString());
-        }
+        try (Writer messageDataWriter = client.sendMessageData()) {
+          if (messageDataWriter == null) {
+            /* Include rejected recipient error messages here to not lose that
+             * information. That piece of the puzzle is vital if zero recipients
+             * are accepted and the server consequently rejects the DATA command.
+             */
+            throw new EmailException(
+                rejected
+                    + "Server "
+                    + smtpHost
+                    + " rejected DATA command: "
+                    + client.getReplyString());
+          }
 
-        render(messageDataWriter, callerHeaders, textBody, htmlBody);
+          render(messageDataWriter, callerHeaders, textBody, htmlBody);
 
-        if (!client.completePendingCommand()) {
-          throw new EmailException(
-              "Server " + smtpHost + " rejected message body: " + client.getReplyString());
-        }
+          if (!client.completePendingCommand()) {
+            throw new EmailException(
+                "Server " + smtpHost + " rejected message body: " + client.getReplyString());
+          }
 
-        client.logout();
-        if (rejected.length() > 0) {
-          throw new EmailException(rejected.toString());
+          client.logout();
+          if (rejected.length() > 0) {
+            throw new EmailException(rejected.toString());
+          }
         }
       } finally {
         client.disconnect();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 507b652..676dbb8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -268,7 +268,7 @@
           + P
           + ident // author
           + P
-          + ident //realAuthor
+          + ident // realAuthor
           + P
           + T // writtenOn
           + 2 // side
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index c11aeef..b408355 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -184,7 +184,7 @@
       // Reload gerrit.config/notedb.config on each migrator invocation, in case a previous
       // migration in the same process modified the on-disk contents. This ensures the defaults for
       // trial/autoMigrate get set correctly below.
-      this.cfg = configProvider.get();
+      this.cfg = configProvider.loadConfig();
       this.sitePaths = sitePaths;
       this.serverIdent = serverIdent;
       this.allUsers = allUsers;
@@ -615,6 +615,7 @@
                                   log.warn(
                                       "Change {} previously failed to rebuild;"
                                           + " skipping primary storage migration",
+                                      id,
                                       e);
                                 } else {
                                   throw e;
@@ -724,6 +725,7 @@
 
       // Only set in-memory state once it's been persisted to storage.
       globalNotesMigration.setFrom(newState);
+      log.info("Migration state: {} => {}", expectedOldState, newState);
 
       return newState;
     }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 19568cf..e4c207b 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -114,7 +114,7 @@
     boolean couldMerge;
     try {
       couldMerge = m.merge(merge.getParents());
-    } catch (IOException e) {
+    } catch (IOException | RuntimeException e) {
       // It is not safe to continue further down in this method as throwing
       // an exception most likely means that the merge tree was not created
       // and m.getMergeResults() is empty. This would mean that all paths are
diff --git a/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index b47bbfb..06e2c45 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 = 11L;
+  public static final long serialVersionUID = 12L;
 
   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/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index d7ecc8a..f17f0b6 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -271,6 +271,7 @@
       if (editsDueToRebase.contains(c) || editsDueToRebase.contains(n)) {
         // Don't combine any edits which were identified as being introduced by a rebase as we would
         // lose that information because of the combination.
+        j++;
         continue;
       }
 
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index 7475fec..083c142 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 = 30L;
+  public static final long serialVersionUID = 31L;
 
   public static final ImmutableBiMap<Whitespace, Character> WHITESPACE_TYPES =
       ImmutableBiMap.of(
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index e49686a..990c318 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Maps;
@@ -377,7 +378,7 @@
 
           case REMOVE_REVIEWER:
           case SUBMIT_AS:
-            return refControl.canPerform(perm.permissionName().get());
+            return refControl.canPerform(changePermissionName(perm));
         }
       } catch (OrmException e) {
         throw new PermissionBackendException("unavailable", e);
@@ -386,11 +387,11 @@
     }
 
     private boolean can(LabelPermission perm) {
-      return !label(perm.permissionName().get()).isEmpty();
+      return !label(labelPermissionName(perm)).isEmpty();
     }
 
     private boolean can(LabelPermission.WithValue perm) {
-      PermissionRange r = label(perm.permissionName().get());
+      PermissionRange r = label(labelPermissionName(perm));
       if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
         return false;
       }
@@ -419,4 +420,11 @@
     }
     return Sets.newHashSetWithExpectedSize(permSet.size());
   }
+
+  private static String changePermissionName(ChangePermission changePermission) {
+    // Within this class, it's programmer error to call this method on a
+    // ChangePermission that isn't associated with a permission name.
+    return DefaultPermissionMappings.changePermissionName(changePermission)
+        .orElseThrow(() -> new IllegalStateException("no name for " + changePermission));
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 4b06861..6a23cdd 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -14,43 +14,37 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.gerrit.common.data.Permission;
-import java.util.Locale;
-import java.util.Optional;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.access.GerritPermission;
 
 public enum ChangePermission implements ChangePermissionOrLabel {
-  READ(Permission.READ),
+  READ,
   RESTORE,
   DELETE,
-  ABANDON(Permission.ABANDON),
-  EDIT_ASSIGNEE(Permission.EDIT_ASSIGNEE),
+  ABANDON,
+  EDIT_ASSIGNEE,
   EDIT_DESCRIPTION,
-  EDIT_HASHTAGS(Permission.EDIT_HASHTAGS),
-  EDIT_TOPIC_NAME(Permission.EDIT_TOPIC_NAME),
-  REMOVE_REVIEWER(Permission.REMOVE_REVIEWER),
-  ADD_PATCH_SET(Permission.ADD_PATCH_SET),
-  REBASE(Permission.REBASE),
-  SUBMIT(Permission.SUBMIT),
-  SUBMIT_AS(Permission.SUBMIT_AS);
+  EDIT_HASHTAGS,
+  EDIT_TOPIC_NAME,
+  REMOVE_REVIEWER,
+  ADD_PATCH_SET,
+  REBASE,
+  SUBMIT,
+  SUBMIT_AS("submit on behalf of other users");
 
-  private final String name;
+  private final String description;
 
   ChangePermission() {
-    name = null;
+    this.description = null;
   }
 
-  ChangePermission(String name) {
-    this.name = name;
-  }
-
-  /** @return name used in {@code project.config} permissions. */
-  @Override
-  public Optional<String> permissionName() {
-    return Optional.ofNullable(name);
+  ChangePermission(String description) {
+    this.description = checkNotNull(description);
   }
 
   @Override
   public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
+    return description != null ? description : GerritPermission.describeEnumValue(this);
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 06c0d73..2824efd 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -14,13 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import java.util.Optional;
+import com.google.gerrit.extensions.api.access.GerritPermission;
 
 /** A {@link ChangePermission} or a {@link LabelPermission}. */
-public interface ChangePermissionOrLabel {
-  /** @return name used in {@code project.config} permissions. */
-  public Optional<String> permissionName();
-
-  /** @return readable identifier of this permission for exception message. */
-  public String describeForException();
-}
+public interface ChangePermissionOrLabel extends GerritPermission {}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 2c8c951..02eed30 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.Sets;
@@ -30,13 +31,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
@@ -84,6 +84,11 @@
     return new WithUserImpl(identifiedUser);
   }
 
+  @Override
+  public boolean usesDefaultCapabilities() {
+    return true;
+  }
+
   class WithUserImpl extends WithUser {
     private final CurrentUser user;
     private Boolean admin;
@@ -101,12 +106,15 @@
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.checkedGet(project);
-        if (state != null) {
-          return projectControlFactory.create(user, state).asForProject().database(db);
-        }
-        return FailedPermissionBackend.project("not found", new NoSuchProjectException(project));
-      } catch (IOException e) {
-        return FailedPermissionBackend.project("unavailable", e);
+        ProjectControl control =
+            PerThreadCache.getOrCompute(
+                PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
+                () -> projectControlFactory.create(user, state));
+        return control.asForProject().database(db);
+      } catch (Exception e) {
+        Throwable cause = e.getCause() != null ? e.getCause() : e;
+        return FailedPermissionBackend.project(
+            "project '" + project.get() + "' is unavailable", cause);
       }
     }
 
@@ -135,7 +143,7 @@
         return can((GlobalPermission) perm);
       } else if (perm instanceof PluginPermission) {
         PluginPermission pluginPermission = (PluginPermission) perm;
-        return has(pluginPermission.permissionName())
+        return has(DefaultPermissionMappings.pluginPermissionName(pluginPermission))
             || (pluginPermission.fallBackToAdmin() && isAdmin());
       }
       throw new PermissionBackendException(perm + " unsupported");
@@ -153,7 +161,7 @@
         case RUN_GC:
         case VIEW_CACHES:
         case VIEW_QUEUE:
-          return has(perm.permissionName()) || can(GlobalPermission.MAINTAIN_SERVER);
+          return has(globalPermissionName(perm)) || can(GlobalPermission.MAINTAIN_SERVER);
 
         case CREATE_ACCOUNT:
         case CREATE_GROUP:
@@ -164,11 +172,12 @@
         case VIEW_ALL_ACCOUNTS:
         case VIEW_CONNECTIONS:
         case VIEW_PLUGINS:
-          return has(perm.permissionName()) || isAdmin();
+        case VIEW_ACCESS:
+          return has(globalPermissionName(perm)) || isAdmin();
 
         case ACCESS_DATABASE:
         case RUN_AS:
-          return has(perm.permissionName());
+          return has(globalPermissionName(perm));
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
@@ -204,7 +213,7 @@
     }
 
     private boolean has(String permissionName) {
-      return allow(capabilities().getPermission(permissionName));
+      return allow(capabilities().getPermission(checkNotNull(permissionName)));
     }
 
     private boolean allow(Collection<PermissionRule> rules) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
new file mode 100644
index 0000000..9593521
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -0,0 +1,169 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.server.permissions.LabelPermission.ForUser;
+import java.util.EnumSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Mappings from {@link com.google.gerrit.extensions.api.access.GerritPermission} enum instances to
+ * the permission names used by {@link DefaultPermissionBackend}.
+ *
+ * <p>These should be considered implementation details of {@code DefaultPermissionBackend}; a
+ * backend that doesn't respect the default permission model will not need to consult these.
+ * However, implementations may also choose to respect certain aspects of the default permission
+ * model, so this class is provided as public to aid those implementations.
+ */
+public class DefaultPermissionMappings {
+  private static final ImmutableBiMap<GlobalPermission, String> CAPABILITIES =
+      ImmutableBiMap.<GlobalPermission, String>builder()
+          .put(GlobalPermission.ACCESS_DATABASE, GlobalCapability.ACCESS_DATABASE)
+          .put(GlobalPermission.ADMINISTRATE_SERVER, GlobalCapability.ADMINISTRATE_SERVER)
+          .put(GlobalPermission.CREATE_ACCOUNT, GlobalCapability.CREATE_ACCOUNT)
+          .put(GlobalPermission.CREATE_GROUP, GlobalCapability.CREATE_GROUP)
+          .put(GlobalPermission.CREATE_PROJECT, GlobalCapability.CREATE_PROJECT)
+          .put(GlobalPermission.EMAIL_REVIEWERS, GlobalCapability.EMAIL_REVIEWERS)
+          .put(GlobalPermission.FLUSH_CACHES, GlobalCapability.FLUSH_CACHES)
+          .put(GlobalPermission.KILL_TASK, GlobalCapability.KILL_TASK)
+          .put(GlobalPermission.MAINTAIN_SERVER, GlobalCapability.MAINTAIN_SERVER)
+          .put(GlobalPermission.MODIFY_ACCOUNT, GlobalCapability.MODIFY_ACCOUNT)
+          .put(GlobalPermission.RUN_AS, GlobalCapability.RUN_AS)
+          .put(GlobalPermission.RUN_GC, GlobalCapability.RUN_GC)
+          .put(GlobalPermission.STREAM_EVENTS, GlobalCapability.STREAM_EVENTS)
+          .put(GlobalPermission.VIEW_ALL_ACCOUNTS, GlobalCapability.VIEW_ALL_ACCOUNTS)
+          .put(GlobalPermission.VIEW_CACHES, GlobalCapability.VIEW_CACHES)
+          .put(GlobalPermission.VIEW_CONNECTIONS, GlobalCapability.VIEW_CONNECTIONS)
+          .put(GlobalPermission.VIEW_PLUGINS, GlobalCapability.VIEW_PLUGINS)
+          .put(GlobalPermission.VIEW_QUEUE, GlobalCapability.VIEW_QUEUE)
+          .put(GlobalPermission.VIEW_ACCESS, GlobalCapability.VIEW_ACCESS)
+          .build();
+
+  static {
+    checkMapContainsAllEnumValues(CAPABILITIES, GlobalPermission.class);
+  }
+
+  private static final ImmutableBiMap<ProjectPermission, String> PROJECT_PERMISSIONS =
+      ImmutableBiMap.<ProjectPermission, String>builder()
+          .put(ProjectPermission.READ, Permission.READ)
+          .build();
+
+  private static final ImmutableBiMap<RefPermission, String> REF_PERMISSIONS =
+      ImmutableBiMap.<RefPermission, String>builder()
+          .put(RefPermission.READ, Permission.READ)
+          .put(RefPermission.CREATE, Permission.CREATE)
+          .put(RefPermission.DELETE, Permission.DELETE)
+          .put(RefPermission.UPDATE, Permission.PUSH)
+          .put(RefPermission.FORGE_AUTHOR, Permission.FORGE_AUTHOR)
+          .put(RefPermission.FORGE_COMMITTER, Permission.FORGE_COMMITTER)
+          .put(RefPermission.FORGE_SERVER, Permission.FORGE_SERVER)
+          .put(RefPermission.CREATE_TAG, Permission.CREATE_TAG)
+          .put(RefPermission.CREATE_SIGNED_TAG, Permission.CREATE_SIGNED_TAG)
+          .put(RefPermission.READ_PRIVATE_CHANGES, Permission.VIEW_PRIVATE_CHANGES)
+          .build();
+
+  private static final ImmutableBiMap<ChangePermission, String> CHANGE_PERMISSIONS =
+      ImmutableBiMap.<ChangePermission, String>builder()
+          .put(ChangePermission.READ, Permission.READ)
+          .put(ChangePermission.ABANDON, Permission.ABANDON)
+          .put(ChangePermission.EDIT_ASSIGNEE, Permission.EDIT_ASSIGNEE)
+          .put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
+          .put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
+          .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
+          .put(ChangePermission.ADD_PATCH_SET, Permission.ADD_PATCH_SET)
+          .put(ChangePermission.REBASE, Permission.REBASE)
+          .put(ChangePermission.SUBMIT, Permission.SUBMIT)
+          .put(ChangePermission.SUBMIT_AS, Permission.SUBMIT_AS)
+          .build();
+
+  private static <T extends Enum<T>> void checkMapContainsAllEnumValues(
+      ImmutableMap<T, String> actual, Class<T> clazz) {
+    Set<T> expected = EnumSet.allOf(clazz);
+    checkState(
+        actual.keySet().equals(expected),
+        "all %s values must be defined, found: %s",
+        clazz.getSimpleName(),
+        actual.keySet());
+  }
+
+  public static String globalPermissionName(GlobalPermission globalPermission) {
+    return checkNotNull(CAPABILITIES.get(globalPermission));
+  }
+
+  public static Optional<GlobalPermission> globalPermission(String capabilityName) {
+    return Optional.ofNullable(CAPABILITIES.inverse().get(capabilityName));
+  }
+
+  public static String pluginPermissionName(PluginPermission pluginPermission) {
+    return pluginPermission.pluginName() + '-' + pluginPermission.capability();
+  }
+
+  public static String globalOrPluginPermissionName(GlobalOrPluginPermission permission) {
+    return permission instanceof GlobalPermission
+        ? globalPermissionName((GlobalPermission) permission)
+        : pluginPermissionName((PluginPermission) permission);
+  }
+
+  public static Optional<String> projectPermissionName(ProjectPermission projectPermission) {
+    return Optional.ofNullable(PROJECT_PERMISSIONS.get(projectPermission));
+  }
+
+  public static Optional<ProjectPermission> projectPermission(String permissionName) {
+    return Optional.ofNullable(PROJECT_PERMISSIONS.inverse().get(permissionName));
+  }
+
+  public static Optional<String> refPermissionName(RefPermission refPermission) {
+    return Optional.ofNullable(REF_PERMISSIONS.get(refPermission));
+  }
+
+  public static Optional<RefPermission> refPermission(String permissionName) {
+    return Optional.ofNullable(REF_PERMISSIONS.inverse().get(permissionName));
+  }
+
+  public static Optional<String> changePermissionName(ChangePermission changePermission) {
+    return Optional.ofNullable(CHANGE_PERMISSIONS.get(changePermission));
+  }
+
+  public static Optional<ChangePermission> changePermission(String permissionName) {
+    return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
+  }
+
+  public static String labelPermissionName(LabelPermission labelPermission) {
+    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+      return Permission.forLabelAs(labelPermission.label());
+    }
+    return Permission.forLabel(labelPermission.label());
+  }
+
+  // TODO(dborowitz): Can these share a common superinterface?
+  public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
+    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+      return Permission.forLabelAs(labelPermission.label());
+    }
+    return Permission.forLabel(labelPermission.label());
+  }
+
+  private DefaultPermissionMappings() {}
+}
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 209de69..35b6e0d 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+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;
@@ -21,6 +23,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackend.WithUser;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Provider;
 import java.util.Collection;
@@ -36,6 +39,14 @@
  * method to the throwing {@code check} or {@code test} methods.
  */
 public class FailedPermissionBackend {
+  public static WithUser user(String message) {
+    return new FailedWithUser(message, null);
+  }
+
+  public static WithUser user(String message, Throwable cause) {
+    return new FailedWithUser(message, cause);
+  }
+
   public static ForProject project(String message) {
     return project(message, null);
   }
@@ -62,6 +73,37 @@
 
   private FailedPermissionBackend() {}
 
+  private static class FailedWithUser extends WithUser {
+    private final String message;
+    private final Throwable cause;
+
+    FailedWithUser(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @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);
+    }
+
+    @Override
+    public void check(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
   private static class FailedProject extends ForProject {
     private final String message;
     private final Throwable cause;
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 4d293c8..a789bd9 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -14,58 +14,46 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.common.collect.ImmutableMap;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermission;
+
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.CapabilityScope;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+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 java.lang.annotation.Annotation;
 import java.util.Collections;
 import java.util.LinkedHashSet;
-import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Global server permissions built into Gerrit. */
 public enum GlobalPermission implements GlobalOrPluginPermission {
-  ACCESS_DATABASE(GlobalCapability.ACCESS_DATABASE),
-  ADMINISTRATE_SERVER(GlobalCapability.ADMINISTRATE_SERVER),
-  CREATE_ACCOUNT(GlobalCapability.CREATE_ACCOUNT),
-  CREATE_GROUP(GlobalCapability.CREATE_GROUP),
-  CREATE_PROJECT(GlobalCapability.CREATE_PROJECT),
-  EMAIL_REVIEWERS(GlobalCapability.EMAIL_REVIEWERS),
-  FLUSH_CACHES(GlobalCapability.FLUSH_CACHES),
-  KILL_TASK(GlobalCapability.KILL_TASK),
-  MAINTAIN_SERVER(GlobalCapability.MAINTAIN_SERVER),
-  MODIFY_ACCOUNT(GlobalCapability.MODIFY_ACCOUNT),
-  RUN_AS(GlobalCapability.RUN_AS),
-  RUN_GC(GlobalCapability.RUN_GC),
-  STREAM_EVENTS(GlobalCapability.STREAM_EVENTS),
-  VIEW_ALL_ACCOUNTS(GlobalCapability.VIEW_ALL_ACCOUNTS),
-  VIEW_CACHES(GlobalCapability.VIEW_CACHES),
-  VIEW_CONNECTIONS(GlobalCapability.VIEW_CONNECTIONS),
-  VIEW_PLUGINS(GlobalCapability.VIEW_PLUGINS),
-  VIEW_QUEUE(GlobalCapability.VIEW_QUEUE);
+  ACCESS_DATABASE,
+  ADMINISTRATE_SERVER,
+  CREATE_ACCOUNT,
+  CREATE_GROUP,
+  CREATE_PROJECT,
+  EMAIL_REVIEWERS,
+  FLUSH_CACHES,
+  KILL_TASK,
+  MAINTAIN_SERVER,
+  MODIFY_ACCOUNT,
+  RUN_AS,
+  RUN_GC,
+  STREAM_EVENTS,
+  VIEW_ALL_ACCOUNTS,
+  VIEW_CACHES,
+  VIEW_CONNECTIONS,
+  VIEW_PLUGINS,
+  VIEW_QUEUE,
+  VIEW_ACCESS;
 
   private static final Logger log = LoggerFactory.getLogger(GlobalPermission.class);
-  private static final ImmutableMap<String, GlobalPermission> BY_NAME;
-
-  static {
-    ImmutableMap.Builder<String, GlobalPermission> m = ImmutableMap.builder();
-    for (GlobalPermission p : values()) {
-      m.put(p.permissionName(), p);
-    }
-    BY_NAME = m.build();
-  }
-
-  @Nullable
-  public static GlobalPermission byName(String name) {
-    return BY_NAME.get(name);
-  }
 
   /**
    * Extracts the {@code @RequiresCapability} or {@code @RequiresAnyCapability} annotation.
@@ -121,23 +109,6 @@
     return fromAnnotation(null, clazz);
   }
 
-  private final String name;
-
-  GlobalPermission(String name) {
-    this.name = name;
-  }
-
-  /** @return name used in {@code project.config} permissions. */
-  @Override
-  public String permissionName() {
-    return name;
-  }
-
-  @Override
-  public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
-  }
-
   private static GlobalOrPluginPermission resolve(
       @Nullable String pluginName,
       String capability,
@@ -160,13 +131,13 @@
       throw new PermissionBackendException("cannot extract permission");
     }
 
-    GlobalPermission perm = byName(capability);
-    if (perm == null) {
+    Optional<GlobalPermission> perm = globalPermission(capability);
+    if (!perm.isPresent()) {
       log.error(
           String.format("Class %s requires unknown capability %s", clazz.getName(), capability));
       throw new PermissionBackendException("cannot extract permission");
     }
-    return perm;
+    return perm.get();
   }
 
   @Nullable
@@ -179,4 +150,9 @@
     }
     return null;
   }
+
+  @Override
+  public String describeForException() {
+    return GerritPermission.describeEnumValue(this);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index 747c997..a80cc15 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -20,9 +20,7 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.server.util.LabelVote;
-import java.util.Optional;
 
 /** Permission representing a label. */
 public class LabelPermission implements ChangePermissionOrLabel {
@@ -83,22 +81,10 @@
     return name;
   }
 
-  /** @return name used in {@code project.config} permissions. */
-  @Override
-  public Optional<String> permissionName() {
-    switch (forUser) {
-      case SELF:
-        return Optional.of(Permission.forLabel(name));
-      case ON_BEHALF_OF:
-        return Optional.of(Permission.forLabelAs(name));
-    }
-    return Optional.empty();
-  }
-
   @Override
   public String describeForException() {
     if (forUser == ON_BEHALF_OF) {
-      return "labelAs " + name;
+      return "label on behalf of " + name;
     }
     return "label " + name;
   }
@@ -228,22 +214,10 @@
       return label.value();
     }
 
-    /** @return name used in {@code project.config} permissions. */
-    @Override
-    public Optional<String> permissionName() {
-      switch (forUser) {
-        case SELF:
-          return Optional.of(Permission.forLabel(label()));
-        case ON_BEHALF_OF:
-          return Optional.of(Permission.forLabelAs(label()));
-      }
-      return Optional.empty();
-    }
-
     @Override
     public String describeForException() {
       if (forUser == ON_BEHALF_OF) {
-        return "labelAs " + label.formatWithEquals();
+        return "label on behalf of " + label.formatWithEquals();
       }
       return "label " + label.formatWithEquals();
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index ed10184..3872a38 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
@@ -40,6 +41,7 @@
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -113,6 +115,29 @@
   public abstract WithUser absentUser(Account.Id user);
 
   /**
+   * Check whether this {@code PermissionBackend} respects the same global capabilities as the
+   * {@link DefaultPermissionBackend}.
+   *
+   * <p>If true, then it makes sense for downstream callers to refer to built-in Gerrit capability
+   * names in user-facing error messages, for example.
+   *
+   * @return whether this is the default permission backend.
+   */
+  public boolean usesDefaultCapabilities() {
+    return false;
+  }
+
+  /**
+   * Throw {@link ResourceNotFoundException} if this backend does not use the default global
+   * capabilities.
+   */
+  public void checkUsesDefaultCapabilities() throws ResourceNotFoundException {
+    if (!usesDefaultCapabilities()) {
+      throw new ResourceNotFoundException("Gerrit capabilities not used on this server");
+    }
+  }
+
+  /**
    * Bulk evaluate a set of {@link PermissionBackendCondition} for view handling.
    *
    * <p>Overridden implementations should call {@link PermissionBackendCondition#set(boolean)} to
@@ -256,6 +281,13 @@
           allowed.add(project);
         } catch (AuthException e) {
           // Do not include this project in allowed.
+        } catch (PermissionBackendException e) {
+          if (e.getCause() instanceof RepositoryNotFoundException) {
+            logger.warn("Could not find repository of the project {} : ", project.get(), e);
+            // Do not include this project because doesn't exist
+          } else {
+            throw e;
+          }
         }
       }
       return allowed;
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index e8e6030..f103462 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -132,7 +132,8 @@
       // project closer to the current one come first.
       sorter.sort(ref, sections);
 
-      // For block permissions, we want a different order: first, we want to go from parent to child.
+      // For block permissions, we want a different order: first, we want to go from parent to
+      // child.
       List<Map.Entry<AccessSection, Project.NameKey>> accessDescending =
           Lists.reverse(Lists.newArrayList(sectionToProject.entrySet()));
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index 37f3726..3fee6cf 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -14,25 +14,26 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.gerrit.common.data.Permission;
-import java.util.Locale;
-import java.util.Optional;
+import static com.google.common.base.Preconditions.checkNotNull;
 
-public enum ProjectPermission {
+import com.google.gerrit.extensions.api.access.GerritPermission;
+import com.google.gerrit.reviewdb.client.RefNames;
+
+public enum ProjectPermission implements GerritPermission {
   /**
    * Can access at least one reference or change within the repository.
    *
    * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
    * references or changes, which can be expensive.
    */
-  ACCESS,
+  ACCESS("access at least one ref"),
 
   /**
    * Can read all references in the repository.
    *
    * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
    */
-  READ(Permission.READ),
+  READ,
 
   /**
    * Can create at least one reference in the project.
@@ -65,16 +66,16 @@
   CREATE_CHANGE,
 
   /** Can run receive pack. */
-  RUN_RECEIVE_PACK,
+  RUN_RECEIVE_PACK("run receive-pack"),
 
   /** Can run upload pack. */
-  RUN_UPLOAD_PACK,
+  RUN_UPLOAD_PACK("run upload-pack"),
 
   /** Allow read access to refs/meta/config. */
-  READ_CONFIG,
+  READ_CONFIG("read " + RefNames.REFS_CONFIG),
 
   /** Allow write access to refs/meta/config. */
-  WRITE_CONFIG,
+  WRITE_CONFIG("write " + RefNames.REFS_CONFIG),
 
   /** Allow banning commits from Gerrit preventing pushes of these commits. */
   BAN_COMMIT,
@@ -83,24 +84,20 @@
   READ_REFLOG,
 
   /** Can push to at least one reference within the repository. */
-  PUSH_AT_LEAST_ONE_REF;
+  PUSH_AT_LEAST_ONE_REF("push to at least one ref");
 
-  private final String name;
+  private final String description;
 
   ProjectPermission() {
-    name = null;
+    this.description = null;
   }
 
-  ProjectPermission(String name) {
-    this.name = name;
+  ProjectPermission(String description) {
+    this.description = checkNotNull(description);
   }
 
-  /** @return name used in {@code project.config} permissions. */
-  public Optional<String> permissionName() {
-    return Optional.ofNullable(name);
-  }
-
+  @Override
   public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
+    return description != null ? description : GerritPermission.describeEnumValue(this);
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 8e9912c..28781ea 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -213,15 +213,6 @@
    * @return {@code true} if the user specified can delete a Git ref.
    */
   private boolean canDelete() {
-    if (RefNames.REFS_CONFIG.equals(refName)) {
-      // Never allow removal of the refs/meta/config branch.
-      // Deleting the branch would destroy all Gerrit specific
-      // metadata about the project, including its access rules.
-      // If a project is to be removed from Gerrit, its repository
-      // should be removed first.
-      return false;
-    }
-
     switch (getUser().getAccessPath()) {
       case GIT:
         return canPushWithForce() || canPerform(Permission.DELETE);
@@ -476,7 +467,7 @@
           return isVisible();
         case CREATE:
           // TODO This isn't an accurate test.
-          return canPerform(perm.permissionName().get());
+          return canPerform(refPermissionName(perm));
         case DELETE:
           return canDelete();
         case UPDATE:
@@ -500,7 +491,7 @@
 
         case CREATE_TAG:
         case CREATE_SIGNED_TAG:
-          return canPerform(perm.permissionName().get());
+          return canPerform(refPermissionName(perm));
 
         case UPDATE_BY_SUBMIT:
           return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
@@ -524,4 +515,11 @@
       throw new PermissionBackendException(perm + " unsupported");
     }
   }
+
+  private static String refPermissionName(RefPermission refPermission) {
+    // Within this class, it's programmer error to call this method on a
+    // RefPermission that isn't associated with a permission name.
+    return DefaultPermissionMappings.refPermissionName(refPermission)
+        .orElseThrow(() -> new IllegalStateException("no name for " + refPermission));
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index 87bd14c..a9f2758 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -14,22 +14,29 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.gerrit.common.data.Permission;
-import java.util.Arrays;
-import java.util.Locale;
-import java.util.Optional;
+import static com.google.common.base.Preconditions.checkNotNull;
 
-public enum RefPermission {
-  READ(Permission.READ),
-  CREATE(Permission.CREATE),
-  DELETE(Permission.DELETE),
-  UPDATE(Permission.PUSH),
+import com.google.gerrit.extensions.api.access.GerritPermission;
+
+public enum RefPermission implements GerritPermission {
+  READ,
+  CREATE,
+
+  /**
+   * Before checking this permission, the caller needs to verify the branch is deletable and reject
+   * early if the branch should never be deleted. For example, the refs/meta/config branch should
+   * never be deleted because deleting this branch would destroy all Gerrit specific metadata about
+   * the project, including its access rules. If a project is to be removed from Gerrit, its
+   * repository should be removed first.
+   */
+  DELETE,
+  UPDATE,
   FORCE_UPDATE,
-  SET_HEAD,
+  SET_HEAD("set HEAD"),
 
-  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
-  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
-  FORGE_SERVER(Permission.FORGE_SERVER),
+  FORGE_AUTHOR,
+  FORGE_COMMITTER,
+  FORGE_SERVER,
   MERGE,
   /**
    * Before checking this permission, the caller should verify {@code USE_SIGNED_OFF_BY} is false.
@@ -41,10 +48,10 @@
   CREATE_CHANGE,
 
   /** Create a tag. */
-  CREATE_TAG(Permission.CREATE_TAG),
+  CREATE_TAG,
 
   /** Create a signed tag. */
-  CREATE_SIGNED_TAG(Permission.CREATE_SIGNED_TAG),
+  CREATE_SIGNED_TAG,
 
   /**
    * Creates changes, then also immediately submits them during {@code push}.
@@ -59,35 +66,26 @@
    * Can read all private changes on the ref. Typically granted to CI systems if they should run on
    * private changes.
    */
-  READ_PRIVATE_CHANGES(Permission.VIEW_PRIVATE_CHANGES),
+  READ_PRIVATE_CHANGES,
 
   /** Read access to ref's config section in {@code project.config}. */
-  READ_CONFIG,
+  READ_CONFIG("read ref config"),
 
   /** Write access to ref's config section in {@code project.config}. */
-  WRITE_CONFIG;
+  WRITE_CONFIG("write ref config");
 
-  private final String name;
+  private final String description;
 
   RefPermission() {
-    name = null;
+    this.description = null;
   }
 
-  RefPermission(String name) {
-    this.name = name;
+  RefPermission(String description) {
+    this.description = checkNotNull(description);
   }
 
-  /** @return name used in {@code project.config} permissions. */
-  public Optional<String> permissionName() {
-    return Optional.ofNullable(name);
-  }
-
+  @Override
   public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
-  }
-
-  /** @return the enum constant for a given permission name if present. */
-  public static Optional<RefPermission> fromName(String name) {
-    return Arrays.stream(RefPermission.values()).filter(p -> name.equals(p.name)).findFirst();
+    return description != null ? description : GerritPermission.describeEnumValue(this);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java b/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
index daee9c7..32adb9c 100644
--- a/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
+++ b/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
@@ -31,13 +31,17 @@
   @Override
   public Class<?> findClass(String name) throws ClassNotFoundException {
     String path = name.replace('.', '/') + ".class";
-    InputStream resource = target.getResourceAsStream(path);
-    if (resource != null) {
-      try {
-        byte[] bytes = ByteStreams.toByteArray(resource);
-        return defineClass(name, bytes, 0, bytes.length);
-      } catch (IOException e) {
+    try (InputStream resource = target.getResourceAsStream(path)) {
+      if (resource != null) {
+        try {
+          byte[] bytes = ByteStreams.toByteArray(resource);
+          return defineClass(name, bytes, 0, bytes.length);
+        } catch (IOException e) {
+          // throws ClassNotFoundException later
+        }
       }
+    } catch (IOException e) {
+      // throws ClassNotFoundException later
     }
     throw new ClassNotFoundException(name);
   }
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index de82370..87c3df7 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -136,7 +136,8 @@
       urls.add(tmp.toUri().toURL());
 
       ClassLoader pluginLoader =
-          new URLClassLoader(urls.toArray(new URL[urls.size()]), PluginUtil.parentFor(type));
+          URLClassLoader.newInstance(
+              urls.toArray(new URL[urls.size()]), PluginUtil.parentFor(type));
 
       JarScanner jarScanner = createJarScanner(tmp);
       PluginConfig pluginConfig = configFactory.getFromGerritConfig(name);
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 954ea29..07ffbdc 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -50,7 +50,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
-import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -79,11 +78,11 @@
   private final PluginGuiceEnvironment env;
   private final ServerInformationImpl srvInfoImpl;
   private final PluginUser.Factory pluginUserFactory;
-  private final ConcurrentMap<String, Plugin> running;
-  private final ConcurrentMap<String, Plugin> disabled;
-  private final Map<String, FileSnapshot> broken;
-  private final Map<Plugin, CleanupHandle> cleanupHandles;
-  private final Queue<Plugin> toCleanup;
+  private final ConcurrentMap<String, Plugin> running = Maps.newConcurrentMap();
+  private final ConcurrentMap<String, Plugin> disabled = Maps.newConcurrentMap();
+  private final Map<String, FileSnapshot> broken = Maps.newHashMap();
+  private final Map<Plugin, CleanupHandle> cleanupHandles = Maps.newConcurrentMap();
+  private final Queue<Plugin> toCleanup = new ArrayDeque<>();
   private final Provider<PluginCleanerTask> cleaner;
   private final PluginScannerThread scanner;
   private final Provider<String> urlProvider;
@@ -108,11 +107,6 @@
     env = pe;
     srvInfoImpl = sii;
     pluginUserFactory = puf;
-    running = Maps.newConcurrentMap();
-    disabled = Maps.newConcurrentMap();
-    broken = new HashMap<>();
-    toCleanup = new ArrayDeque<>();
-    cleanupHandles = Maps.newConcurrentMap();
     cleaner = pct;
     urlProvider = provider;
     persistentCacheFactory = cacheFactory;
@@ -207,7 +201,7 @@
   }
 
   private synchronized void unloadPlugin(Plugin plugin) {
-    persistentCacheFactory.onStop(plugin);
+    persistentCacheFactory.onStop(plugin.getName());
     String name = plugin.getName();
     log.info(String.format("Unloading plugin %s, version %s", name, plugin.getVersion()));
     plugin.stop(env);
@@ -495,7 +489,12 @@
         unloadPlugin(oldPlugin);
       }
       if (!newPlugin.isDisabled()) {
-        newPlugin.start(env);
+        try {
+          newPlugin.start(env);
+        } catch (Throwable e) {
+          newPlugin.stop(env);
+          throw e;
+        }
       }
       if (reload) {
         env.onReloadPlugin(oldPlugin, newPlugin);
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index 3b75256..61a7ef2 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -71,7 +71,8 @@
           .build();
 
   static {
-    // Verify that each BooleanProjectConfig has to/from API mappers in BooleanProjectConfigTransformations
+    // Verify that each BooleanProjectConfig has to/from API mappers in
+    // BooleanProjectConfigTransformations
     if (!Sets.symmetricDifference(
             MAPPER.keySet(), new HashSet<>(Arrays.asList(BooleanProjectConfig.values())))
         .isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index bc70232..516965b 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -17,27 +17,31 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class CommentLinkProvider implements Provider<List<CommentLinkInfo>> {
+@Singleton
+public class CommentLinkProvider implements Provider<List<CommentLinkInfo>>, GerritConfigListener {
   private static final Logger log = LoggerFactory.getLogger(CommentLinkProvider.class);
 
-  private final Config cfg;
+  private volatile List<CommentLinkInfo> commentLinks;
 
   @Inject
   CommentLinkProvider(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
+    this.commentLinks = parseConfig(cfg);
   }
 
-  @Override
-  public List<CommentLinkInfo> get() {
+  private List<CommentLinkInfo> parseConfig(Config cfg) {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
     List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
@@ -54,4 +58,18 @@
     }
     return ImmutableList.copyOf(cls);
   }
+
+  @Override
+  public List<CommentLinkInfo> get() {
+    return commentLinks;
+  }
+
+  @Override
+  public List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event) {
+    if (event.isSectionUpdated(ProjectConfig.COMMENTLINK)) {
+      commentLinks = parseConfig(event.getNewConfig());
+      return Collections.singletonList(event.accept(ProjectConfig.COMMENTLINK));
+    }
+    return Collections.emptyList();
+  }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 9ebcc99..c7858dd 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
@@ -32,19 +33,31 @@
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
-   * @return the cached data; null if no such project exists or a error occurred.
+   * @return the cached data; null if no such project exists, projectName is null or an error
+   *     occurred.
    * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
    */
-  ProjectState get(Project.NameKey projectName);
+  ProjectState get(@Nullable Project.NameKey projectName);
 
   /**
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
    * @throws IOException when there was an error.
-   * @return the cached data; null if no such project exists.
+   * @return the cached data; null if no such project exists or projectName is null.
    */
-  ProjectState checkedGet(Project.NameKey projectName) throws IOException;
+  ProjectState checkedGet(@Nullable Project.NameKey projectName) throws IOException;
+
+  /**
+   * Get the cached data for a project by its unique name.
+   *
+   * @param projectName name of the project.
+   * @param strict true when any error generates an exception
+   * @throws Exception in case of any error (strict = true) or only for I/O or other internal
+   *     errors.
+   * @return the cached data or null when strict = false
+   */
+  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index f9373ac..4975c55 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -142,24 +142,33 @@
       return null;
     }
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return state;
-    } catch (ExecutionException e) {
+      return strictCheckedGet(projectName);
+    } catch (Exception e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
         log.warn(String.format("Cannot read project %s", projectName.get()), e);
         Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
         throw new IOException(e);
       }
-      log.warn("Cannot find project {}", projectName.get(), e);
+      log.debug("Cannot find project {}", projectName.get(), e);
       return null;
     }
   }
 
   @Override
+  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception {
+    return strict ? strictCheckedGet(projectName) : checkedGet(projectName);
+  }
+
+  private ProjectState strictCheckedGet(Project.NameKey projectName) throws Exception {
+    ProjectState state = byName.get(projectName.get());
+    if (state != null && state.needsRefresh(clock.read())) {
+      byName.invalidate(projectName.get());
+      state = byName.get(projectName.get());
+    }
+    return state;
+  }
+
+  @Override
   public void evict(Project p) throws IOException {
     evict(p.getNameKey());
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 66bbcca..10ab746 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -20,8 +20,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -50,25 +48,25 @@
           new ScheduledThreadPoolExecutor(
               config.getInt("cache", "projects", "loadThreads", cpus),
               new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
-      ExecutorService scheduler = Executors.newFixedThreadPool(1);
+      Thread scheduler =
+          new Thread(
+              () -> {
+                for (Project.NameKey name : cache.all()) {
+                  pool.execute(() -> cache.get(name));
+                }
+                pool.shutdown();
+                try {
+                  pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+                  log.info("Finished loading project cache");
+                } catch (InterruptedException e) {
+                  log.warn("Interrupted while waiting for project cache to load");
+                }
+              });
+      scheduler.setName("ProjectCacheWarmer");
+      scheduler.setDaemon(true);
 
       log.info("Loading project cache");
-      scheduler.execute(
-          () -> {
-            for (Project.NameKey name : cache.all()) {
-              pool.execute(
-                  () -> {
-                    cache.get(name);
-                  });
-            }
-            pool.shutdown();
-            try {
-              pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
-              log.info("Finished loading project cache");
-            } catch (InterruptedException e) {
-              log.warn("Interrupted while waiting for project cache to load");
-            }
-          });
+      scheduler.start();
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 15bc54c..b9e0266 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -81,6 +81,21 @@
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
   public static final String COMMENTLINK = "commentlink";
+  public static final String LABEL = "label";
+  public static final String KEY_FUNCTION = "function";
+  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_COPY_MAX_SCORE = "copyMaxScore";
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  public static final String KEY_VALUE = "value";
+  public static final String KEY_CAN_OVERRIDE = "canOverride";
+  public static final String KEY_BRANCH = "branch";
+
   private static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
   private static final String KEY_LINK = "link";
@@ -131,22 +146,6 @@
   private static final String KEY_DEFAULT = "default";
   private static final String KEY_LOCAL_DEFAULT = "local-default";
 
-  private static final String LABEL = "label";
-  private static final String KEY_FUNCTION = "function";
-  private static final String KEY_DEFAULT_VALUE = "defaultValue";
-  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
-  private static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
-  private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
-      "copyAllScoresOnMergeFirstParentUpdate";
-  private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE =
-      "copyAllScoresOnTrivialRebase";
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
-  private static final String KEY_VALUE = "value";
-  private static final String KEY_CAN_OVERRIDE = "canOverride";
-  private static final String KEY_BRANCH = "branch";
-
   private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
   private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
 
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 11327cd..e064265 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -261,7 +261,7 @@
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
     try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(git);
+      cfg.load(git, config.getRevision());
     } catch (IOException | ConfigInvalidException e) {
       log.warn("Failed to load " + fileName + " for " + getName(), e);
     }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index f627ec8..ce236dc 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -49,6 +49,7 @@
   private static final Logger log = LoggerFactory.getLogger(AccountQueryBuilder.class);
 
   public static final String FIELD_ACCOUNT = "account";
+  public static final String FIELD_CAN_SEE = "cansee";
   public static final String FIELD_EMAIL = "email";
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_NAME = "name";
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index b5e7b90..b008092 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -24,8 +23,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
-import java.util.Collection;
-import java.util.Objects;
 
 public class CanSeeChangePredicate extends PostFilterPredicate<AccountState> {
   private final Provider<ReviewDb> db;
@@ -34,6 +31,7 @@
 
   CanSeeChangePredicate(
       Provider<ReviewDb> db, PermissionBackend permissionBackend, ChangeNotes changeNotes) {
+    super(AccountQueryBuilder.FIELD_CAN_SEE, changeNotes.getChangeId().toString());
     this.db = db;
     this.permissionBackend = permissionBackend;
     this.changeNotes = changeNotes;
@@ -56,24 +54,4 @@
   public int getCost() {
     return 1;
   }
-
-  @Override
-  public Predicate<AccountState> copy(Collection<? extends Predicate<AccountState>> children) {
-    return new CanSeeChangePredicate(db, permissionBackend, changeNotes);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(changeNotes.getChange().getChangeId());
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null) {
-      return false;
-    }
-    return getClass() == other.getClass()
-        && changeNotes.getChange().getChangeId()
-            == ((CanSeeChangePredicate) other).changeNotes.getChange().getChangeId();
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index fccb14a..c877544 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -451,6 +451,13 @@
     this.notes = notes;
   }
 
+  /**
+   * If false, omit fields that require database/repo IO.
+   *
+   * <p>This is used to enforce that the dashboard is rendered from the index only. If {@code
+   * lazyLoad} is on, the {@code ChangeData} object will load from the database ("lazily") when a
+   * field accessor is called.
+   */
   public ChangeData setLazyLoad(boolean load) {
     lazyLoad = load;
     return this;
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 1052d33..5d5a95f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -23,12 +23,12 @@
 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.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -89,11 +89,13 @@
               .database(db)
               .test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
-      if (e.getCause() instanceof NoSuchProjectException) {
-        logger.info("No such project: {}", cd.project());
+      Throwable cause = e.getCause();
+      if (cause instanceof RepositoryNotFoundException) {
+        logger.warn(
+            "Skipping change {} because the corresponding repository was not found", cd.getId(), e);
         return false;
       }
-      throw new OrmException("unable to check permissions", e);
+      throw new OrmException("unable to check permissions on change " + cd.getId(), e);
     }
     if (visible) {
       cd.cacheVisibleTo(user);
diff --git a/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
deleted file mode 100644
index 8b08536..0000000
--- a/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.Matchable;
-import com.google.gerrit.index.query.OperatorPredicate;
-
-public abstract class ChangeOperatorPredicate extends OperatorPredicate<ChangeData>
-    implements Matchable<ChangeData> {
-
-  protected ChangeOperatorPredicate(String name, String value) {
-    super(name, value);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 5a04a7c..630f2f3 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -1103,7 +1103,7 @@
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
       d.load(git);
       Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
-      if (destinations != null) {
+      if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
       }
     } catch (RepositoryNotFoundException e) {
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index e853cc0..f870951 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
@@ -77,18 +78,17 @@
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
-    and.add(new CheckConflict(ChangeQueryBuilder.FIELD_CONFLICTS, value, args, c, changeDataCache));
+    and.add(new CheckConflict(value, args, c, changeDataCache));
     return Predicate.and(and);
   }
 
-  private static final class CheckConflict extends ChangeOperatorPredicate {
+  private static final class CheckConflict extends PostFilterPredicate<ChangeData> {
     private final Arguments args;
     private final Branch.NameKey dest;
     private final ChangeDataCache changeDataCache;
 
-    CheckConflict(
-        String field, String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
-      super(field, value);
+    CheckConflict(String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
+      super(ChangeQueryBuilder.FIELD_CONFLICTS, value);
       this.args = args;
       this.dest = c.getDest();
       this.changeDataCache = changeDataCache;
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 7f969e1..a824a87 100644
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-public class DestinationPredicate extends ChangeOperatorPredicate {
+public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
   protected Set<Branch.NameKey> destinations;
 
   public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
diff --git a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index fec7f26..c48bdd5 100644
--- a/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -14,25 +14,22 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-public class OwnerinPredicate extends ChangeOperatorPredicate {
+public class OwnerinPredicate extends PostFilterPredicate<ChangeData> {
   protected final IdentifiedUser.GenericFactory userFactory;
   protected final AccountGroup.UUID uuid;
 
   public OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
-    super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
+    super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.get());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  protected AccountGroup.UUID getAccountGroupUUID() {
-    return uuid;
-  }
-
   @Override
   public boolean match(ChangeData object) throws OrmException {
     final Change change = object.change();
diff --git a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 11f9d89..1894b06 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-public class ReviewerinPredicate extends ChangeOperatorPredicate {
+public class ReviewerinPredicate extends PostFilterPredicate<ChangeData> {
   protected final IdentifiedUser.GenericFactory userFactory;
   protected final AccountGroup.UUID uuid;
 
   public ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
-    super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
+    super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.get());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
index a084b35..a49e8c5 100644
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
@@ -36,4 +36,9 @@
   public GroupMembership getEffectiveGroups() {
     return groups;
   }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index a307d43..6c2a50d 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -25,6 +25,7 @@
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:lang",
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index 2dd54a5..8047b26 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalOrPluginPermissionName;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermission;
+
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -32,6 +35,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.Optional;
 
 @Singleton
 class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
@@ -60,6 +64,7 @@
   @Override
   public Capability parse(AccountResource parent, IdString id)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    permissionBackend.checkUsesDefaultCapabilities();
     IdentifiedUser target = parent.getUser();
     if (self.get() != target) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
@@ -67,16 +72,16 @@
 
     GlobalOrPluginPermission perm = parse(id);
     if (permissionBackend.user(target).test(perm)) {
-      return new AccountResource.Capability(target, perm.permissionName());
+      return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
     }
     throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
     String name = id.get();
-    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
-    if (perm != null) {
-      return perm;
+    Optional<GlobalPermission> perm = globalPermission(name);
+    if (perm.isPresent()) {
+      return perm.get();
     }
 
     int dash = name.lastIndexOf('-');
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 2248e35..57f7d4a 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -20,12 +20,11 @@
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.common.EmailInfo;
-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.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.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -90,9 +89,8 @@
 
   @Override
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException, PermissionBackendException {
+      throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
+          ConfigInvalidException, PermissionBackendException {
     if (input == null) {
       input = new EmailInput();
     }
@@ -110,9 +108,8 @@
 
   /** To be used from plugins that want to create emails without permission checks. */
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException, PermissionBackendException {
+      throws RestApiException, OrmException, EmailException, MethodNotAllowedException, IOException,
+          ConfigInvalidException, PermissionBackendException {
     if (input == null) {
       input = new EmailInput();
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index f278df2..998c6f0 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -61,7 +61,7 @@
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
+    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().seq());
     rsrc.getUser().getUserName().ifPresent(sshKeyCache::evict);
 
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index c623e3e..f38d367 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalOrPluginPermissionName;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.pluginPermissionName;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -23,8 +26,9 @@
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
@@ -75,7 +79,8 @@
   }
 
   @Override
-  public Object apply(AccountResource rsrc) throws AuthException, PermissionBackendException {
+  public Object apply(AccountResource rsrc) throws RestApiException, PermissionBackendException {
+    permissionBackend.checkUsesDefaultCapabilities();
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
     if (self.get() != rsrc.getUser()) {
       perm.check(GlobalPermission.ADMINISTRATE_SERVER);
@@ -84,7 +89,7 @@
 
     Map<String, Object> have = new LinkedHashMap<>();
     for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
-      have.put(p.permissionName(), true);
+      have.put(globalOrPluginPermissionName(p), true);
     }
 
     AccountLimits limits = limitsFactory.create(rsrc.getUser());
@@ -99,7 +104,7 @@
   private Set<GlobalOrPluginPermission> permissionsToTest() {
     Set<GlobalOrPluginPermission> toTest = new HashSet<>();
     for (GlobalPermission p : GlobalPermission.values()) {
-      if (want(p.permissionName())) {
+      if (want(globalPermissionName(p))) {
         toTest.add(p);
       }
     }
@@ -107,7 +112,7 @@
     for (String pluginName : pluginCapabilities.plugins()) {
       for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
         PluginPermission p = new PluginPermission(pluginName, capability);
-        if (want(p.permissionName())) {
+        if (want(pluginPermissionName(p))) {
           toTest.add(p);
         }
       }
@@ -158,8 +163,16 @@
 
   @Singleton
   static class CheckOne implements RestReadView<AccountResource.Capability> {
+    private final PermissionBackend permissionBackend;
+
+    @Inject
+    CheckOne(PermissionBackend permissionBackend) {
+      this.permissionBackend = permissionBackend;
+    }
+
     @Override
-    public BinaryResult apply(Capability resource) {
+    public BinaryResult apply(Capability resource) throws ResourceNotFoundException {
+      permissionBackend.checkUsesDefaultCapabilities();
       return BinaryResult.create("ok\n");
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index cd8dc09..15ad75f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -70,12 +70,12 @@
 
   public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
     SshKeyInfo info = new SshKeyInfo();
-    info.seq = sshKey.getKey().get();
-    info.sshPublicKey = sshKey.getSshPublicKey();
-    info.encodedKey = sshKey.getEncodedKey();
-    info.algorithm = sshKey.getAlgorithm();
-    info.comment = Strings.emptyToNull(sshKey.getComment());
-    info.valid = sshKey.isValid();
+    info.seq = sshKey.seq();
+    info.sshPublicKey = sshKey.sshPublicKey();
+    info.encodedKey = sshKey.encodedKey();
+    info.algorithm = sshKey.algorithm();
+    info.comment = Strings.emptyToNull(sshKey.comment());
+    info.valid = sshKey.valid();
     return info;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index 7f7c2ae..e33f906 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -39,6 +39,7 @@
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
+import java.util.Optional;
 import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -102,10 +103,9 @@
           ConfigInvalidException {
     String userName =
         user.getUserName().orElseThrow(() -> new ResourceConflictException("username must be set"));
-    ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, userName));
-    if (extId == null) {
-      throw new ResourceNotFoundException();
-    }
+    Optional<ExternalId> optionalExtId =
+        externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, userName));
+    ExternalId extId = optionalExtId.orElseThrow(() -> new ResourceNotFoundException());
     accountsUpdateProvider
         .get()
         .update(
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index a30e074..65285c3 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -14,16 +14,22 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.AuthException;
+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.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -32,38 +38,49 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
+  private static final Logger log = LoggerFactory.getLogger(PutPreferred.class);
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final ExternalIds externalIds;
 
   @Inject
   PutPreferred(
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      ExternalIds externalIds) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.externalIds = externalIds;
   }
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, PermissionBackendException,
+          ConfigInvalidException {
     if (self.get() != rsrc.getUser()) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
 
-  public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
+  public Response<String> apply(IdentifiedUser user, String preferredEmail)
+      throws RestApiException, IOException, ConfigInvalidException, OrmException {
+    AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
     accountsUpdateProvider
         .get()
@@ -71,13 +88,68 @@
             "Set Preferred Email via API",
             user.getAccountId(),
             (a, u) -> {
-              if (email.equals(a.getAccount().getPreferredEmail())) {
+              if (preferredEmail.equals(a.getAccount().getPreferredEmail())) {
                 alreadyPreferred.set(true);
               } else {
-                u.setPreferredEmail(email);
+                // check if the user has a matching email
+                String matchingEmail = null;
+                for (String email :
+                    a.getExternalIds()
+                        .stream()
+                        .map(ExternalId::email)
+                        .filter(Objects::nonNull)
+                        .collect(toSet())) {
+                  if (email.equals(preferredEmail)) {
+                    // we have an email that matches exactly, prefer this one
+                    matchingEmail = email;
+                    break;
+                  } else if (matchingEmail == null && email.equalsIgnoreCase(preferredEmail)) {
+                    // we found an email that matches but has a different case
+                    matchingEmail = email;
+                  }
+                }
+
+                if (matchingEmail == null) {
+                  // user doesn't have an external ID for this email
+                  if (user.hasEmailAddress(preferredEmail)) {
+                    // but Realm says the user is allowed to use this email
+                    Set<ExternalId> existingExtIdsWithThisEmail =
+                        externalIds.byEmail(preferredEmail);
+                    if (!existingExtIdsWithThisEmail.isEmpty()) {
+                      // but the email is already assigned to another account
+                      log.warn(
+                          "Cannot set preferred email {} for account {} because it is owned"
+                              + " by the following account(s): {}",
+                          preferredEmail,
+                          user.getAccountId(),
+                          existingExtIdsWithThisEmail
+                              .stream()
+                              .map(ExternalId::accountId)
+                              .collect(toList()));
+                      exception.set(
+                          Optional.of(
+                              new ResourceConflictException("email in use by another account")));
+                      return;
+                    }
+
+                    // claim the email now
+                    u.addExternalId(ExternalId.createEmail(a.getAccount().getId(), preferredEmail));
+                    matchingEmail = preferredEmail;
+                  } else {
+                    // Realm says that the email doesn't belong to the user. This can only happen as
+                    // a race condition because EmailsCollection would have thrown
+                    // ResourceNotFoundException already before invoking this REST endpoint.
+                    exception.set(Optional.of(new ResourceNotFoundException(preferredEmail)));
+                    return;
+                  }
+                }
+                u.setPreferredEmail(matchingEmail);
               }
             })
         .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    if (exception.get().isPresent()) {
+      throw exception.get().get();
+    }
     return alreadyPreferred.get() ? Response.ok("") : Response.created("");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index 4024c10..073d724 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -42,6 +42,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -109,8 +110,8 @@
               u -> u.addExternalId(ExternalId.create(key, accountId, null, null)));
     } catch (OrmDuplicateKeyException dupeErr) {
       // If we are using this identity, don't report the exception.
-      ExternalId other = externalIds.get(key);
-      if (other != null && other.accountId().equals(accountId)) {
+      Optional<ExternalId> other = externalIds.get(key);
+      if (other.isPresent() && other.get().accountId().equals(accountId)) {
         return input.username;
       }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 5ea72c0..2ecd0b9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -172,6 +172,7 @@
   private final Config gerritConfig;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
+  private final boolean strictLabels;
 
   @Inject
   PostReview(
@@ -211,6 +212,7 @@
     this.gerritConfig = gerritConfig;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
+    this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
   @Override
@@ -372,7 +374,10 @@
         reviewerResult.gatherResults();
       }
 
-      emailReviewers(revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify);
+      boolean readyForReview =
+          (output.ready != null && output.ready) || !revision.getChange().isWorkInProgress();
+      emailReviewers(
+          revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify, readyForReview);
     }
 
     return Response.ok(output);
@@ -405,7 +410,8 @@
       Change change,
       List<PostReviewers.Addition> reviewerAdditions,
       @Nullable NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean readyForReview) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
     List<Address> toByEmail = new ArrayList<>();
@@ -423,7 +429,8 @@
       reviewerAdditions
           .get(0)
           .op
-          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
+          .emailReviewers(
+              change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify, readyForReview);
     }
   }
 
@@ -445,8 +452,12 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType type = labelTypes.byLabel(ent.getKey());
       if (type == null) {
-        throw new BadRequestException(
-            String.format("label \"%s\" is not a configured label", ent.getKey()));
+        if (strictLabels) {
+          throw new BadRequestException(
+              String.format("label \"%s\" is not a configured label", ent.getKey()));
+        }
+        itr.remove();
+        continue;
       }
 
       if (!caller.isInternalUser()) {
@@ -485,8 +496,12 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType lt = labelTypes.byLabel(ent.getKey());
       if (lt == null) {
-        throw new BadRequestException(
-            String.format("label \"%s\" is not a configured label", ent.getKey()));
+        if (strictLabels) {
+          throw new BadRequestException(
+              String.format("label \"%s\" is not a configured label", ent.getKey()));
+        }
+        itr.remove();
+        continue;
       }
 
       if (ent.getValue() == null || ent.getValue() == 0) {
@@ -496,8 +511,12 @@
       }
 
       if (lt.getValue(ent.getValue()) == null) {
-        throw new BadRequestException(
-            String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
+        if (strictLabels) {
+          throw new BadRequestException(
+              String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
+        }
+        itr.remove();
+        continue;
       }
 
       short val = ent.getValue();
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 2049929..46955e8 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -51,7 +51,6 @@
 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;
@@ -92,7 +91,6 @@
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
   private final AccountsCollection accounts;
-  private final ReviewerResource.Factory reviewerFactory;
   private final PermissionBackend permissionBackend;
 
   private final GroupsCollection groupsCollection;
@@ -113,7 +111,6 @@
   @Inject
   PostReviewers(
       AccountsCollection accounts,
-      ReviewerResource.Factory reviewerFactory,
       PermissionBackend permissionBackend,
       GroupsCollection groupsCollection,
       GroupMembers groupMembers,
@@ -132,7 +129,6 @@
       OutgoingEmailValidator validator) {
     super(retryHelper);
     this.accounts = accounts;
-    this.reviewerFactory = reviewerFactory;
     this.permissionBackend = permissionBackend;
     this.groupsCollection = groupsCollection;
     this.groupMembers = groupMembers;
@@ -237,12 +233,12 @@
       boolean allowGroup,
       boolean allowByEmail)
       throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
-    IdentifiedUser user = null;
+    IdentifiedUser reviewerUser = null;
     boolean exactMatchFound = false;
     try {
-      user = accounts.parse(reviewer);
-      if (reviewer.equalsIgnoreCase(user.getName())
-          || reviewer.equals(String.valueOf(user.getAccountId()))) {
+      reviewerUser = accounts.parse(reviewer);
+      if (reviewer.equalsIgnoreCase(reviewerUser.getName())
+          || reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
       }
     } catch (UnprocessableEntityException | AuthException e) {
@@ -255,22 +251,20 @@
       return null;
     }
 
-    ReviewerResource rrsrc = reviewerFactory.create(rsrc, user.getAccountId());
-    Account member = rrsrc.getReviewerUser().getAccount();
     PermissionBackend.ForRef perm =
-        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
-    if (isValidReviewer(member, perm)) {
+        permissionBackend.absentUser(reviewerUser.getAccountId()).ref(rsrc.getChange().getDest());
+    if (isValidReviewer(reviewerUser.getAccount(), perm)) {
       return new Addition(
           reviewer,
           rsrc,
-          ImmutableSet.of(member.getId()),
+          ImmutableSet.of(reviewerUser.getAccountId()),
           null,
           state,
           notify,
           accountsToNotify,
           exactMatchFound);
     }
-    if (!member.isActive()) {
+    if (!reviewerUser.getAccount().isActive()) {
       if (allowByEmail && state == CC) {
         return null;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
index 79e8507..665040d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
@@ -202,7 +202,8 @@
         reviewersByEmail,
         addedCCsByEmail,
         notify,
-        accountsToNotify);
+        accountsToNotify,
+        !rsrc.getChange().isWorkInProgress());
     if (!addedReviewers.isEmpty()) {
       List<AccountState> reviewers =
           addedReviewers
@@ -221,7 +222,8 @@
       Collection<Address> addedByEmail,
       Collection<Address> copiedByEmail,
       NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean readyForReview) {
     if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
       return;
     }
@@ -250,7 +252,7 @@
       AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
       // Default to silent operation on WIP changes.
       NotifyHandling defaultNotifyHandling =
-          change.isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
+          readyForReview ? NotifyHandling.ALL : NotifyHandling.NONE;
       cm.setNotify(MoreObjects.firstNonNull(notify, defaultNotifyHandling));
       cm.setAccountsToNotify(accountsToNotify);
       cm.setFrom(userId);
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 78687cd..dae37d6 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -29,10 +29,10 @@
 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.FanOutExecutor;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectState;
@@ -54,6 +54,7 @@
 import java.util.Set;
 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.stream.Stream;
@@ -78,7 +79,7 @@
   private final Config config;
   private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final WorkQueue workQueue;
+  private final ExecutorService executor;
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
 
@@ -87,7 +88,7 @@
       ChangeQueryBuilder changeQueryBuilder,
       DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
       Provider<InternalChangeQuery> queryProvider,
-      WorkQueue workQueue,
+      @FanOutExecutor ExecutorService executor,
       Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       @GerritServerConfig Config config) {
@@ -95,7 +96,7 @@
     this.config = config;
     this.queryProvider = queryProvider;
     this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
-    this.workQueue = workQueue;
+    this.executor = executor;
     this.dbProvider = dbProvider;
     this.approvalsUtil = approvalsUtil;
   }
@@ -150,7 +151,7 @@
 
     try {
       List<Future<Set<SuggestedReviewer>>> futures =
-          workQueue.getDefaultQueue().invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
+          executor.invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
       Iterator<Double> weightIterator = weights.iterator();
       for (Future<Set<SuggestedReviewer>> f : futures) {
         double weight = weightIterator.next();
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 9cf52b3..557d77a 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -87,7 +87,7 @@
     if (id.get().equals("current")) {
       PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
       if (ps != null && visible(change)) {
-        return new RevisionResource(change, ps).doNotCache();
+        return RevisionResource.createNonCachable(change, ps);
       }
       throw new ResourceNotFoundException(id);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index aff1979..bbfe75d 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static java.util.Collections.reverseOrder;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -41,6 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.List;
 import org.kohsuke.args4j.Option;
@@ -56,6 +59,9 @@
   private final EnumSet<ListChangesOption> jsonOpt =
       EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.SUBMITTABLE);
 
+  private static final Comparator<ChangeData> COMPARATOR =
+      Comparator.comparing(ChangeData::project).thenComparing(cd -> cd.getId().id, reverseOrder());
+
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -129,7 +135,7 @@
       if (c.getStatus().isOpen()) {
         ChangeSet cs =
             mergeSuperSet.get().completeChangeSet(dbProvider.get(), c, resource.getUser());
-        cds = cs.changes().asList();
+        cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
         cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
@@ -143,14 +149,7 @@
         throw new AuthException("change would be submitted with a change that you cannot see");
       }
 
-      if (cds.size() <= 1 && hidden == 0) {
-        cds = Collections.emptyList();
-      } else {
-        // Skip sorting for singleton lists, to avoid WalkSorter opening the
-        // repo just to fill out the commit field in PatchSetData.
-        cds = sort(cds);
-      }
-
+      cds = sort(cds, hidden);
       SubmittedTogetherInfo info = new SubmittedTogetherInfo();
       info.changes = json.create(jsonOpt).lazyLoad(lazyLoad).formatChangeDatas(cds);
       info.nonVisibleChanges = hidden;
@@ -161,11 +160,42 @@
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds) throws OrmException, IOException {
+  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws OrmException, IOException {
+    if (cds.size() <= 1 && hidden == 0) {
+      // Skip sorting for singleton lists, to avoid WalkSorter opening the
+      // repo just to fill out the commit field in PatchSetData.
+      return Collections.emptyList();
+    }
+
+    long numProjectsDistinct = cds.stream().map(ChangeData::project).distinct().count();
+    long numProjects = cds.stream().map(ChangeData::project).count();
+
+    if (numProjects == numProjectsDistinct || numProjectsDistinct > 5) {
+      // We either have only a single change per project which means that WalkSorter won't make a
+      // difference compared to our index-backed sort, or we are looking at more than 5 projects
+      // which would make WalkSorter too expensive for this call.
+      return cds.stream().sorted(COMPARATOR).collect(toList());
+    }
+
+    // Perform more expensive walk-sort.
     List<ChangeData> sorted = new ArrayList<>(cds.size());
     for (PatchSetData psd : sorter.get().sort(cds)) {
       sorted.add(psd.data());
     }
     return sorted;
   }
+
+  private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds)
+      throws OrmException {
+    // TODO(hiesel): Instead of calling these manually, either implement a helper that brings a
+    // database-backed change on-par with an index-backed change in terms of the populated fields in
+    // ChangeData or check if any of the ChangeDatas was loaded from the database and allow
+    // lazyloading if so.
+    for (ChangeData cd : cds) {
+      cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
+      cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_STRICT);
+      cd.currentPatchSet();
+    }
+    return cds;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index dcb35ab..d32abe8 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -14,9 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.config.GerritConfigListenerHelper.acceptIfChanged;
+
 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;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -104,4 +108,12 @@
             "maxWithoutConfirmation",
             PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
   }
+
+  public static GerritConfigListener configListener() {
+    return acceptIfChanged(
+        ConfigKey.create("suggest", "maxSuggestedReviewers"),
+        ConfigKey.create("suggest", "accounts"),
+        ConfigKey.create("addreviewer", "maxAllowed"),
+        ConfigKey.create("addreviewer", "maxWithoutConfirmation"));
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
index 90fa5c4..0b94d16 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
@@ -37,6 +37,7 @@
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "reload").to(ReloadConfig.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index 853e156..412b88d 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -18,9 +18,11 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.CapabilityConstants;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,16 +38,20 @@
   private static final Logger log = LoggerFactory.getLogger(ListCapabilities.class);
   private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9-]+$");
 
+  private final PermissionBackend permissionBackend;
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
-  public ListCapabilities(DynamicMap<CapabilityDefinition> pluginCapabilities) {
+  public ListCapabilities(
+      PermissionBackend permissionBackend, DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.permissionBackend = permissionBackend;
     this.pluginCapabilities = pluginCapabilities;
   }
 
   @Override
   public Map<String, CapabilityInfo> apply(ConfigResource resource)
-      throws IllegalAccessException, NoSuchFieldException {
+      throws ResourceNotFoundException, IllegalAccessException, NoSuchFieldException {
+    permissionBackend.checkUsesDefaultCapabilities();
     return ImmutableMap.<String, CapabilityInfo>builder()
         .putAll(collectCoreCapabilities())
         .putAll(collectPluginCapabilities())
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
new file mode 100644
index 0000000..0c9cf17
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -0,0 +1,83 @@
+// 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.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.extensions.api.config.ConfigUpdateEntryInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
+import com.google.gerrit.server.config.GerritServerConfigReloader;
+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.Inject;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ReloadConfig implements RestModifyView<ConfigResource, Input> {
+
+  private GerritServerConfigReloader config;
+  private PermissionBackend permissions;
+
+  @Inject
+  ReloadConfig(GerritServerConfigReloader config, PermissionBackend permissions) {
+    this.config = config;
+    this.permissions = permissions;
+  }
+
+  @Override
+  public Map<String, List<ConfigUpdateEntryInfo>> apply(ConfigResource resource, Input input)
+      throws RestApiException, PermissionBackendException {
+    permissions.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    List<ConfigUpdatedEvent.Update> updates = config.reloadConfig();
+
+    Map<String, List<ConfigUpdateEntryInfo>> reply = new HashMap<>();
+    for (UpdateResult result : UpdateResult.values()) {
+      reply.put(result.name().toLowerCase(), new ArrayList<>());
+    }
+    if (updates.isEmpty()) {
+      return reply;
+    }
+    updates
+        .stream()
+        .forEach(u -> reply.get(u.getResult().name().toLowerCase()).addAll(toEntryInfos(u)));
+    return reply;
+  }
+
+  private static List<ConfigUpdateEntryInfo> toEntryInfos(ConfigUpdatedEvent.Update update) {
+    return update
+        .getConfigUpdates()
+        .stream()
+        .map(e -> toConfigUpdateEntryInfo(e))
+        .collect(toImmutableList());
+  }
+
+  private static ConfigUpdateEntryInfo toConfigUpdateEntryInfo(ConfigUpdateEntry e) {
+    ConfigUpdateEntryInfo uei = new ConfigUpdateEntryInfo();
+    uei.configKey = e.key.toString();
+    uei.oldValue = e.oldVal;
+    uei.newValue = e.newVal;
+    return uei;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 2c0653a..865f077 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -29,6 +29,7 @@
 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;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -67,7 +68,8 @@
   public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
       throws OrmException, PermissionBackendException, RestApiException, IOException,
           ConfigInvalidException {
-    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
+
     rsrc.getProjectState().checkStatePermitsRead();
 
     if (input == null) {
@@ -102,7 +104,7 @@
       if (Strings.isNullOrEmpty(input.ref)) {
         throw new BadRequestException("must set 'ref' when specifying 'permission'");
       }
-      Optional<RefPermission> rp = RefPermission.fromName(input.permission);
+      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
       if (!rp.isPresent()) {
         throw new BadRequestException(
             String.format("'%s' is not recognized as ref permission", input.permission));
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
new file mode 100644
index 0000000..b14a16d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
@@ -0,0 +1,63 @@
+// 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.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+public class CheckAccessReadView implements RestReadView<ProjectResource> {
+  String refName;
+  String account;
+  String permission;
+
+  @Inject CheckAccess checkAccess;
+
+  @Option(name = "--ref", usage = "ref name to check permission for")
+  void addOption(String refName) {
+    this.refName = refName;
+  }
+
+  @Option(name = "--account", usage = "account to check acccess for")
+  void setAccount(String account) {
+    this.account = account;
+  }
+
+  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
+  void setPermission(String perm) {
+    this.permission = perm;
+  }
+
+  @Override
+  public AccessCheckInfo apply(ProjectResource rsrc)
+      throws OrmException, PermissionBackendException, RestApiException, IOException,
+          ConfigInvalidException {
+
+    AccessCheckInput input = new AccessCheckInput();
+    input.ref = refName;
+    input.account = account;
+    input.permission = permission;
+
+    return checkAccess.apply(rsrc, input);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index f5bfb94..1529dae 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -119,6 +119,7 @@
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
       ObjectId oldCommit = config.getRevision();
+      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
 
       setAccess.validateChanges(config, removals, additions);
       setAccess.applyChanges(config, removals, additions);
@@ -141,7 +142,7 @@
           config.commitToNewRef(
               md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
 
-      if (commit.name().equals(oldCommit.getName())) {
+      if (commit.name().equals(oldCommitSha1)) {
         throw new BadRequestException("no change");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 9f3c473..6305d5d 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -178,11 +180,17 @@
         BranchInfo info = new BranchInfo();
         info.ref = ref;
         info.revision = revid.getName();
-        info.canDelete =
-            permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
-                    && rsrc.getProjectState().statePermitsWrite()
-                ? true
-                : null;
+
+        if (isConfigRef(name.get())) {
+          // Never allow to delete the meta config branch.
+          info.canDelete = null;
+        } else {
+          info.canDelete =
+              permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
+                      && rsrc.getProjectState().statePermitsWrite()
+                  ? true
+                  : null;
+        }
         return info;
       } catch (IOException err) {
         log.error("Cannot create branch \"" + name + "\"", err);
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index 89213a0..aed372c 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.extensions.common.Input;
+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;
@@ -52,6 +54,12 @@
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
       throws RestApiException, OrmException, IOException, PermissionBackendException {
+    if (isConfigRef(rsrc.getBranchKey().get())) {
+      // Never allow to delete the meta config branch.
+      throw new MethodNotAllowedException(
+          "not allowed to delete branch " + rsrc.getBranchKey().get());
+    }
+
     permissionBackend.currentUser().ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
     rsrc.getProjectState().checkStatePermitsWrite();
 
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index c51fc56..13b21c9 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+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;
@@ -220,16 +221,21 @@
     }
     command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
 
-    try {
-      permissionBackend
-          .currentUser()
-          .project(project.getNameKey())
-          .ref(refName)
-          .check(RefPermission.DELETE);
-    } catch (AuthException denied) {
-      command.setResult(
-          Result.REJECTED_OTHER_REASON,
-          "it doesn't exist or you do not have permission to delete it");
+    if (isConfigRef(refName)) {
+      // Never allow to delete the meta config branch.
+      command.setResult(Result.REJECTED_OTHER_REASON, "not allowed to delete branch " + refName);
+    } else {
+      try {
+        permissionBackend
+            .currentUser()
+            .project(project.getNameKey())
+            .ref(refName)
+            .check(RefPermission.DELETE);
+      } catch (AuthException denied) {
+        command.setResult(
+            Result.REJECTED_OTHER_REASON,
+            "it doesn't exist or you do not have permission to delete it");
+      }
     }
 
     if (!project.getProjectState().statePermitsWrite()) {
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index a3886bc..bd5f444 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -44,6 +47,12 @@
   public Response<?> apply(TagResource resource, Input input)
       throws OrmException, RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
+
+    if (isConfigRef(tag)) {
+      // Never allow to delete the meta config branch.
+      throw new MethodNotAllowedException("not allowed to delete " + tag);
+    }
+
     permissionBackend
         .currentUser()
         .project(resource.getNameKey())
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index b6fa6d0..ed9dede 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
@@ -200,11 +202,16 @@
         branches.add(b);
 
         if (!Constants.HEAD.equals(ref.getName())) {
-          b.canDelete =
-              perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE)
-                      && rsrc.getProjectState().statePermitsWrite()
-                  ? true
-                  : null;
+          if (isConfigRef(ref.getName())) {
+            // Never allow to delete the meta config branch.
+            b.canDelete = null;
+          } else {
+            b.canDelete =
+                perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE)
+                        && rsrc.getProjectState().statePermitsWrite()
+                    ? true
+                    : null;
+          }
         }
         continue;
       }
@@ -247,12 +254,18 @@
     BranchInfo info = new BranchInfo();
     info.ref = ref.getName();
     info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-    info.canDelete =
-        !targets.contains(ref.getName())
-                && perm.testOrFalse(RefPermission.DELETE)
-                && projectState.statePermitsWrite()
-            ? true
-            : null;
+
+    if (isConfigRef(ref.getName())) {
+      // Never allow to delete the meta config branch.
+      info.canDelete = null;
+    } else {
+      info.canDelete =
+          !targets.contains(ref.getName())
+                  && perm.testOrFalse(RefPermission.DELETE)
+                  && projectState.statePermitsWrite()
+              ? true
+              : null;
+    }
 
     BranchResource rsrc = new BranchResource(projectState, user, ref);
     for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 9a8232e..3407d39 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -577,7 +577,8 @@
       try {
         // 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
-        // be allowed for other users). Allowing project owners to access here will help them to view
+        // be allowed for other users). Allowing project owners to access here will help them to
+        // view
         // and update the config of hidden projects easily.
         ProjectPermission permissionToCheck =
             state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 31ec7e1..ec6a99b 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
@@ -41,7 +43,6 @@
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -183,10 +184,16 @@
 
   public static TagInfo createTagInfo(
       PermissionBackend.ForRef perm, Ref ref, RevWalk rw, ProjectState projectState, WebLinks links)
-      throws MissingObjectException, IOException {
+      throws IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
-    Boolean canDelete =
-        perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
+
+    Boolean canDelete = null;
+    if (!isConfigRef(ref.getName())) {
+      // Never allow to delete the meta config branch.
+      canDelete =
+          perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
+    }
+
     List<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 67380dc..337084c 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -50,6 +50,7 @@
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
     post(PROJECT_KIND, "check.access").to(CheckAccess.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index f118ff2..ca7eb06 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -200,7 +200,7 @@
         ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
         if (projectConfigEntry != null) {
           if (!PARAMETER_NAME_PATTERN.matcher(v.getKey()).matches()) {
-            //TODO check why we have this restriction
+            // TODO check why we have this restriction
             log.warn(
                 "Parameter name '{}' must match '{}'",
                 v.getKey(),
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 2671aaf..422c749 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -214,7 +214,7 @@
    * @param identifiedUser the user
    * @param config the config to modify
    * @param projectName the project for which to change access.
-   * @param newParentProjectName the new parent to set.
+   * @param newParentProjectName the new parent to set; passing null will make this a nop
    * @param checkAdmin if set, verify that user has administrateServer permission
    */
   public void setParentName(
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index d7a614d..6ef11fa 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -175,7 +175,7 @@
       Path jarPath = rulesDir.resolve("rules-" + rulesId.getName() + ".jar");
       if (Files.isRegularFile(jarPath)) {
         URL[] cp = new URL[] {toURL(jarPath)};
-        return save(newEmptyMachine(new URLClassLoader(cp, systemLoader)));
+        return save(newEmptyMachine(URLClassLoader.newInstance(cp, systemLoader)));
       }
     }
 
diff --git a/java/com/google/gerrit/server/rules/StoredValue.java b/java/com/google/gerrit/server/rules/StoredValue.java
index c3bc53f..593d474 100644
--- a/java/com/google/gerrit/server/rules/StoredValue.java
+++ b/java/com/google/gerrit/server/rules/StoredValue.java
@@ -58,7 +58,7 @@
   public T get(Prolog engine) {
     T obj = getOrNull(engine);
     if (obj == null) {
-      //unless createValue() is overridden, will return null
+      // unless createValue() is overridden, will return null
       obj = createValue(engine);
       if (obj == null) {
         throw new SystemException("No " + key + " available");
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index 2292234..32a14db 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -16,6 +16,7 @@
         "//lib:guava",
         "//lib:gwtorm",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/commons:dbcp",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
diff --git a/java/com/google/gerrit/server/schema/MySql.java b/java/com/google/gerrit/server/schema/MySql.java
index f4f19f0..e5f59d7 100644
--- a/java/com/google/gerrit/server/schema/MySql.java
+++ b/java/com/google/gerrit/server/schema/MySql.java
@@ -41,7 +41,8 @@
     b.append(port(dbs.optional("port")));
     b.append("/");
     b.append(dbs.required("database"));
-    // See https://stackoverflow.com/questions/42084633/table-name-pattern-can-not-be-null-or-empty-in-java
+    // See
+    // https://stackoverflow.com/questions/42084633/table-name-pattern-can-not-be-null-or-empty-in-java
     b.append("?nullNamePatternMatchesAll=true");
     return b.toString();
   }
diff --git a/java/com/google/gerrit/server/schema/Schema_105.java b/java/com/google/gerrit/server/schema/Schema_105.java
index 78ecdbd..dd5e71a7 100644
--- a/java/com/google/gerrit/server/schema/Schema_105.java
+++ b/java/com/google/gerrit/server/schema/Schema_105.java
@@ -66,7 +66,8 @@
   private Set<String> listChangesIndexes(JdbcSchema schema) throws SQLException {
     // List of all changes indexes ever created or dropped, found with the
     // following command:
-    //   find g* -name \*.sql | xargs git log -i -p -S' index changes_' | grep -io ' index changes_\w*' | cut -d' ' -f3 | tr A-Z a-z | sort -u
+    //   find g* -name \*.sql | xargs git log -i -p -S' index changes_' | grep -io ' index
+    // changes_\w*' | cut -d' ' -f3 | tr A-Z a-z | sort -u
     // Used rather than listIndexes as we're not sure whether it might include
     // primary key indexes.
     Set<String> allChanges =
diff --git a/java/com/google/gerrit/server/schema/Schema_124.java b/java/com/google/gerrit/server/schema/Schema_124.java
index 497d5f2..8746427 100644
--- a/java/com/google/gerrit/server/schema/Schema_124.java
+++ b/java/com/google/gerrit/server/schema/Schema_124.java
@@ -84,11 +84,8 @@
         Account.Id accountId = new Account.Id(rs.getInt(1));
         int seq = rs.getInt(2);
         String sshPublicKey = rs.getString(3);
-        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq), sshPublicKey);
         boolean valid = toBoolean(rs.getString(4));
-        if (!valid) {
-          key.setInvalid();
-        }
+        AccountSshKey key = AccountSshKey.create(accountId, seq, sshPublicKey, valid);
         imports.put(accountId, key);
       }
     }
@@ -122,15 +119,13 @@
   }
 
   private Collection<AccountSshKey> fixInvalidSequenceNumbers(Collection<AccountSshKey> keys) {
-    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.seq()));
     List<AccountSshKey> fixedKeys = new ArrayList<>(keys);
     AccountSshKey minKey = o.min(keys);
-    while (minKey.getKey().get() <= 0) {
+    while (minKey.seq() <= 0) {
       AccountSshKey fixedKey =
-          new AccountSshKey(
-              new AccountSshKey.Id(
-                  minKey.getKey().getParentKey(), Math.max(o.max(keys).getKey().get() + 1, 1)),
-              minKey.getSshPublicKey());
+          AccountSshKey.create(
+              minKey.accountId(), Math.max(o.max(keys).seq() + 1, 1), minKey.sshPublicKey());
       Collections.replaceAll(fixedKeys, minKey, fixedKey);
       minKey = o.min(fixedKeys);
     }
diff --git a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
index 308d0cc..387242c 100644
--- a/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
+++ b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.ssh;
 
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
@@ -37,7 +38,8 @@
   public void evict(String username) {}
 
   @Override
-  public AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException {
+  public AccountSshKey create(Account.Id accountId, int seq, String encoded)
+      throws InvalidSshKeyException {
     throw new InvalidSshKeyException();
   }
 }
diff --git a/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
index d078c43..55ba5ed 100644
--- a/java/com/google/gerrit/server/ssh/SshKeyCreator.java
+++ b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.ssh;
 
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 
 public interface SshKeyCreator {
-  AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException;
+  AccountSshKey create(Account.Id accountId, int seq, String encoded) throws InvalidSshKeyException;
 }
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c8e4e1d..aceb824 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.SendEmailExecutor;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index e60869a..fa20ad9 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -215,8 +215,7 @@
 
     List<ChangeData> result = new ArrayList<>();
     Iterable<ChangeData> destChanges =
-        MergeSuperSet.query(queryProvider.get())
-            .byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
+        queryProvider.get().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
     for (ChangeData chd : destChanges) {
       result.add(chd);
     }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 6b663cc..7dd1ac9 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -235,6 +235,7 @@
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyUtil notifyUtil;
   private final RetryHelper retryHelper;
+  private final ChangeData.Factory changeDataFactory;
 
   private Timestamp ts;
   private RequestId submissionId;
@@ -262,7 +263,8 @@
       Provider<MergeOpRepoManager> ormProvider,
       NotifyUtil notifyUtil,
       TopicMetrics topicMetrics,
-      RetryHelper retryHelper) {
+      RetryHelper retryHelper,
+      ChangeData.Factory changeDataFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -275,6 +277,7 @@
     this.notifyUtil = notifyUtil;
     this.retryHelper = retryHelper;
     this.topicMetrics = topicMetrics;
+    this.changeDataFactory = changeDataFactory;
   }
 
   @Override
@@ -443,14 +446,21 @@
 
     logDebug("Beginning integration of {}", change);
     try {
-      ChangeSet cs = mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+      ChangeSet indexBackedChangeSet =
+          mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
       checkState(
-          cs.ids().contains(change.getId()), "change %s missing from %s", change.getId(), cs);
-      if (cs.furtherHiddenChanges()) {
+          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");
       }
-      logDebug("Calculated to merge {}", cs);
+      logDebug("Calculated to merge {}", 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
@@ -471,7 +481,6 @@
               openRepoManager();
             }
             this.commitStatus = new CommitStatus(cs, isRetry);
-            MergeSuperSet.reloadChanges(cs);
             if (checkSubmitRules) {
               logDebug("Checking submit rules and state");
               checkSubmitRulesAndState(cs, isRetry);
@@ -514,6 +523,18 @@
     orm.setContext(db, ts, caller, submissionId);
   }
 
+  private ChangeSet reloadChanges(ChangeSet changeSet) {
+    List<ChangeData> visible = new ArrayList<>(changeSet.changes().size());
+    List<ChangeData> nonVisible = new ArrayList<>(changeSet.nonVisibleChanges().size());
+    changeSet
+        .changes()
+        .forEach(c -> visible.add(changeDataFactory.create(db, c.project(), c.getId())));
+    changeSet
+        .nonVisibleChanges()
+        .forEach(c -> nonVisible.add(changeDataFactory.create(db, c.project(), c.getId())));
+    return new ChangeSet(visible, nonVisible);
+  }
+
   private class RetryTracker implements RetryListener {
     long lastAttemptNumber;
 
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 6ffe4fb..3e9f068 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,12 +18,12 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -53,25 +53,6 @@
  */
 public class MergeSuperSet {
 
-  public static void reloadChanges(ChangeSet changeSet) throws OrmException {
-    // Clear exactly the fields requested by query(InternalChangeQuery) below.
-    for (ChangeData cd : changeSet.changes()) {
-      cd.reloadChange();
-      cd.setPatchSets(null);
-      cd.setMergeable(null);
-    }
-  }
-
-  public static InternalChangeQuery query(InternalChangeQuery q) {
-    // Request fields required for completing the ChangeSet and converting to
-    // ChangeInfo without having to touch the database or opening the repository
-    // more than necessary. This provides reasonable performance when loading
-    // the change screen; callers that care about reading the latest value of
-    // these fields should clear them explicitly using reloadChanges().
-    return q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET, ChangeField.MERGEABLE);
-  }
-
-  private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
   private final DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation;
@@ -85,14 +66,12 @@
   @Inject
   MergeSuperSet(
       @GerritServerConfig Config cfg,
-      ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
       ProjectCache projectCache) {
     this.cfg = cfg;
-    this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
     this.repoManagerProvider = repoManagerProvider;
     this.mergeSuperSetComputation = mergeSuperSetComputation;
@@ -118,8 +97,9 @@
         orm = repoManagerProvider.get();
         closeOrm = true;
       }
-
-      ChangeData cd = changeDataFactory.create(db, change.getProject(), change.getId());
+      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(
@@ -217,7 +197,7 @@
   }
 
   private List<ChangeData> byTopicOpen(String topic) throws OrmException {
-    return query(queryProvider.get()).byTopicOpen(topic);
+    return queryProvider.get().byTopicOpen(topic);
   }
 
   private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index 10a6a31..7e9c47e 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -34,6 +34,6 @@
    *
    * @param ctx context
    */
-  //TODO(dborowitz): Support async operations?
+  // TODO(dborowitz): Support async operations?
   default void postUpdate(Context ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 07ae04d..9cdb006 100644
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ChangeUpdateExecutor;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 3ed1f2f..c36d68b 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -25,6 +25,7 @@
         "//lib:jsch",
         "//lib:servlet-api-3_1",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/commons:codec",
         "//lib/dropwizard:dropwizard-core",
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheEntry.java b/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
index 206b279..8e962e3 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
@@ -15,20 +15,19 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountSshKey;
 import java.security.PublicKey;
 
 class SshKeyCacheEntry {
-  private final AccountSshKey.Id id;
+  private final Account.Id accountId;
   private final PublicKey publicKey;
 
-  SshKeyCacheEntry(AccountSshKey.Id i, PublicKey k) {
-    id = i;
-    publicKey = k;
+  SshKeyCacheEntry(Account.Id accountId, PublicKey publicKey) {
+    this.accountId = accountId;
+    this.publicKey = publicKey;
   }
 
   Account.Id getAccount() {
-    return id.getParentKey();
+    return accountId;
   }
 
   boolean match(PublicKey inkey) {
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 2f039f1..3ab7a58 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -35,6 +35,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
@@ -101,14 +102,14 @@
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
-      ExternalId user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
-      if (user == null) {
+      Optional<ExternalId> user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+      if (!user.isPresent()) {
         return NO_SUCH_USER;
       }
 
       List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-      for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
-        if (k.isValid()) {
+      for (AccountSshKey k : authorizedKeys.getKeys(user.get().accountId())) {
+        if (k.valid()) {
           add(kl, k);
         }
       }
@@ -121,7 +122,7 @@
 
     private void add(List<SshKeyCacheEntry> kl, AccountSshKey k) {
       try {
-        kl.add(new SshKeyCacheEntry(k.getKey(), SshUtil.parse(k)));
+        kl.add(new SshKeyCacheEntry(k.accountId(), SshUtil.parse(k)));
       } catch (OutOfMemoryError e) {
         // This is the only case where we assume the problem has nothing
         // to do with the key object, and instead we must abort this load.
@@ -134,11 +135,11 @@
 
     private void markInvalid(AccountSshKey k) {
       try {
-        log.info("Flagging SSH key " + k.getKey() + " invalid");
-        authorizedKeys.markKeyInvalid(k.getAccount(), k.getKey().get());
-        k.setInvalid();
+        log.info("Flagging SSH key " + k.seq() + " of account " + k.accountId() + " invalid");
+        authorizedKeys.markKeyInvalid(k.accountId(), k.seq());
       } catch (IOException | ConfigInvalidException e) {
-        log.error("Failed to mark SSH key" + k.getKey() + " invalid", e);
+        log.error(
+            "Failed to mark SSH key " + k.seq() + " of account " + k.accountId() + " invalid", e);
       }
     }
   }
diff --git a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
index b838e07..bb47e3f 100644
--- a/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.ssh.SshKeyCreator;
 import java.security.NoSuchAlgorithmException;
@@ -27,9 +28,10 @@
   private static final Logger log = LoggerFactory.getLogger(SshKeyCreatorImpl.class);
 
   @Override
-  public AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException {
+  public AccountSshKey create(Account.Id accountId, int seq, String encoded)
+      throws InvalidSshKeyException {
     try {
-      AccountSshKey key = new AccountSshKey(id, SshUtil.toOpenSshPublicKey(encoded));
+      AccountSshKey key = AccountSshKey.create(accountId, seq, SshUtil.toOpenSshPublicKey(encoded));
       SshUtil.parse(key);
       return key;
     } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 6465a30..b6c2d19 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.audit.AuditService;
 import com.google.gerrit.server.audit.SshAuditEvent;
+import com.google.gerrit.server.config.ConfigKey;
+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.util.SystemLog;
@@ -30,6 +33,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.Collections;
+import java.util.List;
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -37,7 +42,7 @@
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-class SshLog implements LifecycleListener {
+class SshLog implements LifecycleListener, GerritConfigListener {
   private static final Logger log = Logger.getLogger(SshLog.class);
   private static final String LOG_NAME = "sshd_log";
   private static final String P_SESSION = "session";
@@ -50,8 +55,11 @@
 
   private final Provider<SshSession> session;
   private final Provider<Context> context;
-  private final AsyncAppender async;
+  private volatile AsyncAppender async;
   private final AuditService auditService;
+  private final SystemLog systemLog;
+
+  private final Object lock = new Object();
 
   @Inject
   SshLog(
@@ -63,12 +71,34 @@
     this.session = session;
     this.context = context;
     this.auditService = auditService;
+    this.systemLog = systemLog;
 
-    if (!config.getBoolean("sshd", "requestLog", true)) {
-      async = null;
-      return;
+    if (config.getBoolean("sshd", "requestLog", true)) {
+      enableLogging();
     }
-    async = systemLog.createAsyncAppender(LOG_NAME, new SshLogLayout());
+  }
+
+  /** @return true if a change in state has occurred */
+  public boolean enableLogging() {
+    synchronized (lock) {
+      if (async == null) {
+        async = systemLog.createAsyncAppender(LOG_NAME, new SshLogLayout());
+        return true;
+      }
+      return false;
+    }
+  }
+
+  /** @return true if a change in state has occurred */
+  public boolean disableLogging() {
+    synchronized (lock) {
+      if (async != null) {
+        async.close();
+        async = null;
+        return true;
+      }
+      return false;
+    }
   }
 
   @Override
@@ -76,9 +106,7 @@
 
   @Override
   public void stop() {
-    if (async != null) {
-      async.close();
-    }
+    disableLogging();
   }
 
   void onLogin() {
@@ -288,4 +316,23 @@
     }
     return commandName.toString();
   }
+
+  @Override
+  public List<ConfigUpdatedEvent.Update> configUpdated(ConfigUpdatedEvent event) {
+    ConfigKey sshdRequestLog = ConfigKey.create("sshd", "requestLog");
+    if (!event.isValueUpdated(sshdRequestLog)) {
+      return Collections.emptyList();
+    }
+
+    boolean enabled = event.getNewConfig().getBoolean("sshd", "requestLog", true);
+    boolean stateUpdated;
+    if (enabled) {
+      stateUpdated = enableLogging();
+    } else {
+      stateUpdated = disableLogging();
+    }
+    return stateUpdated
+        ? Collections.singletonList(event.accept(sshdRequestLog))
+        : Collections.emptyList();
+  }
 }
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index 03ed74f..27a431c 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -21,10 +21,12 @@
 import com.google.common.base.Splitter;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
+import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider;
@@ -71,6 +73,8 @@
     configureAliases();
 
     bind(SshLog.class);
+    DynamicSet.bind(binder(), GerritConfigListener.class).to(SshLog.class);
+
     bind(SshInfo.class).to(SshDaemon.class).in(SINGLETON);
     factory(DispatchCommand.Factory.class);
     factory(QueryShell.Factory.class);
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index 6fb83f0..b37ca8b 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -51,7 +51,7 @@
   public static PublicKey parse(AccountSshKey key)
       throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
     try {
-      final String s = key.getEncodedKey();
+      final String s = key.encodedKey();
       if (s == null) {
         throw new InvalidKeySpecException("No key string");
       }
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 85a108a..0e36d53 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -53,6 +53,7 @@
     command(gerrit, ListGroupsCommand.class);
     command(gerrit, LsUserRefs.class);
     command(gerrit, Query.class);
+    command(gerrit, ReloadConfig.class);
     command(gerrit, ShowCaches.class);
     command(gerrit, ShowConnections.class);
     command(gerrit, ShowQueue.class);
diff --git a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index 6b3e6d7..0804d08 100644
--- a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.lucene.LuceneVersionManager;
 import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -30,15 +30,19 @@
   @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to activate")
   private String name;
 
-  @Inject private LuceneVersionManager luceneVersionManager;
+  @Inject private VersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.activateLatestIndex(name)) {
-        stdout.println("Activated latest index version");
+      if (versionManager.isKnownIndex(name)) {
+        if (versionManager.activateLatestIndex(name)) {
+          stdout.println("Activated latest index version");
+        } else {
+          stdout.println("Not activating index, already using latest version");
+        }
       } else {
-        stdout.println("Not activating index, already using latest version");
+        stderr.println(String.format("Cannot activate index %s: unknown", name));
       }
     } catch (ReindexerAlreadyRunningException e) {
       throw die("Failed to activate latest index: " + e.getMessage());
diff --git a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index d00468a..599c9dc 100644
--- a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -14,20 +14,31 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.CommandName;
 import com.google.gerrit.sshd.Commands;
 import com.google.gerrit.sshd.DispatchCommandProvider;
+import com.google.inject.Injector;
+import com.google.inject.Key;
 
 public class IndexCommandsModule extends CommandModule {
 
+  private final Injector injector;
+
+  public IndexCommandsModule(Injector injector) {
+    this.injector = injector;
+  }
+
   @Override
   protected void configure() {
     CommandName gerrit = Commands.named("gerrit");
     CommandName index = Commands.named(gerrit, "index");
     command(index).toProvider(new DispatchCommandProvider(index));
-    command(index, IndexActivateCommand.class);
-    command(index, IndexStartCommand.class);
+    if (injector.getExistingBinding(Key.get(VersionManager.class)) != null) {
+      command(index, IndexActivateCommand.class);
+      command(index, IndexStartCommand.class);
+    }
     command(index, IndexChangesCommand.class);
     command(index, IndexProjectCommand.class);
   }
diff --git a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index fb9b482..f3d349c 100644
--- a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.lucene.LuceneVersionManager;
 import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -34,15 +34,19 @@
   @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to start")
   private String name;
 
-  @Inject private LuceneVersionManager luceneVersionManager;
+  @Inject private VersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.startReindexer(name, force)) {
-        stdout.println("Reindexer started");
+      if (versionManager.isKnownIndex(name)) {
+        if (versionManager.startReindexer(name, force)) {
+          stdout.println("Reindexer started");
+        } else {
+          stdout.println("Nothing to reindex, index is already the latest version");
+        }
       } else {
-        stdout.println("Nothing to reindex, index is already the latest version");
+        stderr.println(String.format("Cannot reindex %s: unknown", name));
       }
     } catch (ReindexerAlreadyRunningException e) {
       throw die("Failed to start reindexer: " + e.getMessage());
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
new file mode 100644
index 0000000..20145d2
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.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.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.config.ConfigUpdatedEvent;
+import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
+import com.google.gerrit.server.config.GerritServerConfigReloader;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** Issues a reload of gerrit.config. */
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+  name = "reload-config",
+  description = "Reloads the Gerrit configuration",
+  runsAt = MASTER_OR_SLAVE
+)
+public class ReloadConfig extends SshCommand {
+
+  @Inject private GerritServerConfigReloader gerritServerConfigReloader;
+
+  @Override
+  protected void run() throws Failure {
+    List<ConfigUpdatedEvent.Update> updates = gerritServerConfigReloader.reloadConfig();
+    if (updates.isEmpty()) {
+      stdout.println("No config entries updated!");
+      return;
+    }
+
+    // Print out UpdateResult.{ACCEPTED|REJECTED} entries grouped by their type
+    for (UpdateResult updateResult : UpdateResult.values()) {
+      List<ConfigUpdatedEvent.Update> filteredUpdates = filterUpdates(updates, updateResult);
+      if (filteredUpdates.isEmpty()) {
+        continue;
+      }
+      stdout.println(updateResult.toString() + " configuration changes:");
+      filteredUpdates
+          .stream()
+          .flatMap(update -> update.getConfigUpdates().stream())
+          .forEach(cfgEntry -> stdout.println(cfgEntry.toString()));
+    }
+  }
+
+  public static List<ConfigUpdatedEvent.Update> filterUpdates(
+      List<ConfigUpdatedEvent.Update> updates, UpdateResult result) {
+    return updates
+        .stream()
+        .filter(update -> update.getResult() == result)
+        .collect(Collectors.toList());
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 04f7d3c..bc1e084 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -263,8 +263,7 @@
   private void deleteSshKey(SshKeyInfo i)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
           ConfigInvalidException, PermissionBackendException {
-    AccountSshKey sshKey =
-        new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
+    AccountSshKey sshKey = AccountSshKey.create(user.getAccountId(), i.seq, i.sshPublicKey);
     deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 4957a60..bea4da13 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -66,8 +66,7 @@
       reset();
     } else {
       for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
-          logger.hasMoreElements();
-          ) {
+          logger.hasMoreElements(); ) {
         Logger log = logger.nextElement();
         if (name == null || log.getName().contains(name)) {
           log.setLevel(Level.toLevel(level.name()));
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 9846825..f2fe4c2 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -26,12 +26,14 @@
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//lib:gwtorm",
         "//lib:h2",
         "//lib:truth",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/guice",
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index e549e08..224a5bf 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.testing;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
@@ -24,6 +26,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -48,6 +51,11 @@
   }
 
   @Override
+  public synchronized Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
+    return ImmutableMap.copyOf(Maps.filterKeys(byId, accountIds::contains));
+  }
+
+  @Override
   public synchronized Optional<AccountState> getByUsername(String username) {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 471f4fa..b63830e 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -29,11 +29,13 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
@@ -42,12 +44,14 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.ChangeUpdateExecutor;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
 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.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
@@ -62,7 +66,6 @@
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.mail.SendEmailExecutor;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
@@ -82,7 +85,6 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
-import com.google.gerrit.server.update.ChangeUpdateExecutor;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
@@ -227,7 +229,8 @@
             return MoreExecutors.newDirectExecutorService();
           }
         });
-    install(new DefaultCacheFactory.Module());
+    install(new DefaultMemoryCacheModule());
+    install(new H2CacheModule());
     install(new FakeEmailSender.Module());
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
@@ -271,6 +274,13 @@
 
   @Provides
   @Singleton
+  @FanOutExecutor
+  public ExecutorService createChangeJsonExecutor() {
+    return MoreExecutors.newDirectExecutorService();
+  }
+
+  @Provides
+  @Singleton
   @GerritServerId
   public String createServerId() {
     String serverId =
diff --git a/javatests/com/google/gerrit/server/query/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
similarity index 63%
rename from javatests/com/google/gerrit/server/query/IndexConfig.java
rename to java/com/google/gerrit/testing/IndexConfig.java
index 87452b5..9cace88 100644
--- a/javatests/com/google/gerrit/server/query/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.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,25 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query;
+package com.google.gerrit.testing;
 
 import org.eclipse.jgit.lib.Config;
 
 public class IndexConfig {
+  public static Config create() {
+    return createFromExistingConfig(new Config());
+  }
+
+  public static Config createFromExistingConfig(Config cfg) {
+    cfg.setInt("index", null, "maxPages", 10);
+    cfg.setString("trackingid", "query-bug", "footer", "Bug:");
+    cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
+    cfg.setString("trackingid", "query-bug", "system", "querytests");
+    cfg.setString("trackingid", "query-feature", "footer", "Feature");
+    cfg.setString("trackingid", "query-feature", "match", "QUERY\\d{2,8}");
+    cfg.setString("trackingid", "query-feature", "system", "querytests");
+    return cfg;
+  }
 
   public static Config createForLucene() {
     return create();
@@ -31,10 +45,4 @@
 
     return cfg;
   }
-
-  public static Config create() {
-    Config cfg = new Config();
-    cfg.setInt("index", null, "maxPages", 10);
-    return cfg;
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index fce28de..bed2504 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -20,12 +20,16 @@
 import com.google.gerrit.common.Nullable;
 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.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
@@ -224,7 +228,7 @@
     Ref nonMetaConfig = createRef("refs/heads/master");
 
     try (ProjectResetter resetProject =
-        builder(null, null, null, projectCache)
+        builder(null, null, null, null, null, null, projectCache)
             .build(new ProjectResetter.Config().reset(project).reset(project2))) {
       updateRef(nonMetaConfig);
       updateRef(repo2, metaConfig);
@@ -244,7 +248,7 @@
     EasyMock.replay(projectCache);
 
     try (ProjectResetter resetProject =
-        builder(null, null, null, projectCache)
+        builder(null, null, null, null, null, null, projectCache)
             .build(new ProjectResetter.Config().reset(project).reset(project2))) {
       createRef("refs/heads/master");
       createRef(repo2, RefNames.REFS_CONFIG);
@@ -274,7 +278,7 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null)
+        builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       updateRef(allUsersRepo, userBranch);
@@ -300,7 +304,7 @@
     EasyMock.replay(accountIndexer);
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null)
+        builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       // Non-user branch because it's not in All-Users.
       createRef(RefNames.refsUsers(new Account.Id(2)));
@@ -339,7 +343,7 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null)
+        builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       updateRef(allUsersRepo, externalIds);
@@ -376,7 +380,7 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null)
+        builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
@@ -398,7 +402,7 @@
     EasyMock.replay(accountCreator);
 
     try (ProjectResetter resetProject =
-        builder(accountCreator, null, null, null)
+        builder(accountCreator, null, null, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
@@ -406,6 +410,39 @@
     EasyMock.verify(accountCreator);
   }
 
+  @Test
+  public void groupEviction() throws Exception {
+    AccountGroup.UUID uuid1 = new AccountGroup.UUID("abcd1");
+    AccountGroup.UUID uuid2 = new AccountGroup.UUID("abcd2");
+    AccountGroup.UUID uuid3 = new AccountGroup.UUID("abcd3");
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+
+    GroupCache cache = EasyMock.createNiceMock(GroupCache.class);
+    GroupIndexer indexer = EasyMock.createNiceMock(GroupIndexer.class);
+    GroupIncludeCache includeCache = EasyMock.createNiceMock(GroupIncludeCache.class);
+    cache.evict(uuid2);
+    indexer.index(uuid2);
+    includeCache.evictParentGroupsOf(uuid2);
+    cache.evict(uuid3);
+    indexer.index(uuid3);
+    includeCache.evictParentGroupsOf(uuid3);
+    EasyMock.expectLastCall();
+
+    EasyMock.replay(cache, indexer);
+
+    Ref ref1 = createRef(allUsersRepo, RefNames.refsGroups(uuid1));
+    Ref ref2 = createRef(allUsersRepo, RefNames.refsGroups(uuid2));
+    try (ProjectResetter resetProject =
+        builder(null, null, null, cache, includeCache, indexer, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
+      updateRef(allUsersRepo, ref2);
+      createRef(allUsersRepo, RefNames.refsGroups(uuid3));
+    }
+
+    EasyMock.verify(cache, indexer);
+  }
+
   private Ref createRef(String ref) throws IOException {
     return createRef(repo, ref);
   }
@@ -474,13 +511,16 @@
   }
 
   private ProjectResetter.Builder builder() {
-    return builder(null, null, null, null);
+    return builder(null, null, null, null, null, null, null);
   }
 
   private ProjectResetter.Builder builder(
       @Nullable AccountCreator accountCreator,
       @Nullable AccountCache accountCache,
       @Nullable AccountIndexer accountIndexer,
+      @Nullable GroupCache groupCache,
+      @Nullable GroupIncludeCache groupIncludeCache,
+      @Nullable GroupIndexer groupIndexer,
       @Nullable ProjectCache projectCache) {
     return new ProjectResetter.Builder(
         repoManager,
@@ -488,6 +528,9 @@
         accountCreator,
         accountCache,
         accountIndexer,
+        groupCache,
+        groupIncludeCache,
+        groupIndexer,
         projectCache);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
new file mode 100644
index 0000000..3c7b966
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -0,0 +1,75 @@
+// 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.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class TestGroupBackendTest extends AbstractDaemonTest {
+  @Inject private DynamicSet<GroupBackend> groupBackends;
+  @Inject private UniversalGroupBackend universalGroupBackend;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID testUUID = new AccountGroup.UUID("testbackend:test");
+
+  @Test
+  public void handlesTestGroup() throws Exception {
+    assertThat(testGroupBackend.handles(testUUID)).isTrue();
+  }
+
+  @Test
+  public void universalGroupBackendHandlesTestGroup() throws Exception {
+    RegistrationHandle registrationHandle = groupBackends.add(testGroupBackend);
+    try {
+      assertThat(universalGroupBackend.handles(testUUID)).isTrue();
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void doesNotHandleLDAP() throws Exception {
+    assertThat(testGroupBackend.handles(new AccountGroup.UUID("ldap:1234"))).isFalse();
+  }
+
+  @Test
+  public void doesNotHandleNull() throws Exception {
+    assertThat(testGroupBackend.handles(null)).isFalse();
+  }
+
+  @Test
+  public void returnsNullWhenGroupDoesNotExist() throws Exception {
+    assertThat(testGroupBackend.get(testUUID)).isNull();
+  }
+
+  @Test
+  public void returnsNullForNullGroup() throws Exception {
+    assertThat(testGroupBackend.get(null)).isNull();
+  }
+
+  @Test
+  public void returnsKnownGroup() throws Exception {
+    testGroupBackend.create(testUUID);
+    assertThat(testGroupBackend.get(testUUID)).isNotNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index b1606ee..e88e662 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -33,7 +33,6 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -41,23 +40,27 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
+import com.google.common.truth.Correspondence;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AccountCreator;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
 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.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -71,6 +74,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -97,6 +101,7 @@
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.ProjectWatches;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -119,6 +124,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
+import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -130,6 +136,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -201,10 +208,14 @@
 
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
 
+  @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
+
   @Inject
   @Named("accounts")
   private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
 
+  @Inject private AccountOperations accountOperations;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -262,6 +273,26 @@
     }
   }
 
+  protected void assertLabelPermission(
+      Project.NameKey project,
+      GroupReference groupReference,
+      String ref,
+      boolean exclusive,
+      String labelName,
+      int min,
+      int max)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+
+    String permissionName = Permission.LABEL + labelName;
+    Permission permission = accessSection.getPermission(permissionName);
+    assertPermission(permission, permissionName, exclusive, labelName);
+    assertPermissionRule(
+        permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
+  }
+
   @Test
   public void createByAccountCreator() throws Exception {
     Account.Id accountId = createByAccountCreator(2); // account creation + external ID creation
@@ -271,22 +302,6 @@
         RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
   }
 
-  @Test
-  @UseSsh
-  public void createWithSshKeysByAccountCreator() throws Exception {
-    Account.Id accountId =
-        createByAccountCreator(3); // account creation + external ID creation + adding SSH keys
-    refUpdateCounter.assertRefUpdateFor(
-        ImmutableMap.of(
-            RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
-            2,
-            RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-            1,
-            RefUpdateCounter.projectRef(
-                allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS),
-            1));
-  }
-
   private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
     String name = "foo";
     TestAccount foo = accountCreator.create(name);
@@ -1018,10 +1033,12 @@
     String userRefName = RefNames.refsUsers(user.id);
 
     // remove default READ permissions
-    ProjectConfig cfg = projectCache.checkedGet(allUsers).getConfig();
-    cfg.getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-        .remove(new Permission(Permission.READ));
-    saveProjectConfig(allUsers, cfg);
+    try (ProjectConfigUpdate u = updateProject(allUsers)) {
+      u.getConfig()
+          .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
+          .remove(new Permission(Permission.READ));
+      u.save();
+    }
 
     // deny READ permission that is inherited from All-Projects
     deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
@@ -1825,18 +1842,18 @@
   @Test
   @UseSsh
   public void sshKeys() throws Exception {
-    //
     // The test account should initially have exactly one ssh key
     List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(1);
     assertSequenceNumbers(info);
     SshKeyInfo key = info.get(0);
-    String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
+    KeyPair keyPair = sshKeys.getKeyPair(admin);
+    String inital = TestSshKeys.publicKey(keyPair, admin.email);
     assertThat(key.sshPublicKey).isEqualTo(inital);
     accountIndexedCounter.assertNoReindex();
 
     // Add a new key
-    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
+    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
@@ -1851,7 +1868,7 @@
     accountIndexedCounter.assertNoReindex();
 
     // Add another new key
-    String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
+    String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey2);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
@@ -1865,6 +1882,16 @@
     assertThat(info.get(0).seq).isEqualTo(1);
     assertThat(info.get(1).seq).isEqualTo(3);
     accountIndexedCounter.assertReindexOf(admin);
+
+    // Mark first key as invalid
+    assertThat(info.get(0).valid).isTrue();
+    authorizedKeys.markKeyInvalid(admin.id, 1);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertThat(info.get(0).seq).isEqualTo(1);
+    assertThat(info.get(0).valid).isFalse();
+    assertThat(info.get(1).seq).isEqualTo(3);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -1982,15 +2009,24 @@
   }
 
   @Test
-  public void groups() throws Exception {
-    assertGroups(
-        admin.username, ImmutableList.of("Anonymous Users", "Registered Users", "Administrators"));
+  public void allGroupsForAnAdminAccountCanBeRetrieved() throws Exception {
+    List<GroupInfo> groups = gApi.accounts().id(admin.username).getGroups();
+    assertThat(groups)
+        .comparingElementsUsing(getGroupToNameCorrespondence())
+        .containsExactly("Anonymous Users", "Registered Users", "Administrators");
+  }
 
-    assertGroups(user.username, ImmutableList.of("Anonymous Users", "Registered Users"));
-
+  @Test
+  public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
+    String username = name("user1");
+    accountOperations.newAccount().username(username).create();
     String group = createGroup("group");
-    String newUser = createAccount("user1", group);
-    assertGroups(newUser, ImmutableList.of("Anonymous Users", "Registered Users", group));
+    gApi.groups().id(group).addMembers(username);
+
+    List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
+    assertThat(allGroups)
+        .comparingElementsUsing(getGroupToNameCorrespondence())
+        .containsExactly("Anonymous Users", "Registered Users", group);
   }
 
   @Test
@@ -2368,13 +2404,19 @@
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
   }
 
-  private void assertGroups(String user, List<String> expected) throws Exception {
-    List<String> actual = getNamesOfGroupsOfUser(user);
-    assertThat(actual).containsExactlyElementsIn(expected);
-  }
+  private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
+    return new Correspondence<GroupInfo, String>() {
+      @Override
+      public boolean compare(GroupInfo actualGroup, String expectedName) {
+        String groupName = actualGroup == null ? null : actualGroup.name;
+        return Objects.equals(groupName, expectedName);
+      }
 
-  private List<String> getNamesOfGroupsOfUser(String user) throws RestApiException {
-    return gApi.accounts().id(user).getGroups().stream().map(g -> g.name).collect(toList());
+      @Override
+      public String toString() {
+        return "has name";
+      }
+    };
   }
 
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 607d7d0..ed5459d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -183,9 +183,9 @@
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
 
-    ExternalId gerritExtId = externalIds.get(gerritExtIdKey);
-    assertThat(gerritExtId).isNotNull();
-    assertThat(gerritExtId.email()).isEqualTo(newEmail);
+    Optional<ExternalId> gerritExtId = externalIds.get(gerritExtIdKey);
+    assertThat(gerritExtId).isPresent();
+    assertThat(gerritExtId.get().email()).isEqualTo(newEmail);
 
     Optional<AccountState> accountState = accounts.get(accountId);
     assertThat(accountState).isPresent();
@@ -420,9 +420,9 @@
     }
 
     // Verify that the email in the external ID was not updated.
-    ExternalId gerritExtId = externalIds.get(gerritExtIdKey);
-    assertThat(gerritExtId).isNotNull();
-    assertThat(gerritExtId.email()).isEqualTo(email);
+    Optional<ExternalId> gerritExtId = externalIds.get(gerritExtIdKey);
+    assertThat(gerritExtId).isPresent();
+    assertThat(gerritExtId.get().email()).isEqualTo(email);
 
     // Verify that the preferred email was not updated.
     Optional<AccountState> accountState = accounts.get(accountId);
@@ -537,7 +537,7 @@
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
     for (ExternalId.Key extIdKey : extIdKeys) {
-      assertThat(externalIds.get(extIdKey)).named(extIdKey.get()).isNull();
+      assertThat(externalIds.get(extIdKey)).named(extIdKey.get()).isEmpty();
     }
   }
 
@@ -557,14 +557,14 @@
       @Nullable Account.Id expectedAccountId,
       @Nullable String expectedEmail)
       throws Exception {
-    ExternalId extId = externalIds.get(extIdKey);
-    assertThat(extId).named(extIdKey.get()).isNotNull();
+    Optional<ExternalId> extId = externalIds.get(extIdKey);
+    assertThat(extId).named(extIdKey.get()).isPresent();
     if (expectedAccountId != null) {
-      assertThat(extId.accountId())
+      assertThat(extId.get().accountId())
           .named("account ID of " + extIdKey.get())
           .isEqualTo(expectedAccountId);
     }
-    assertThat(extId.email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
+    assertThat(extId.get().email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
   }
 
   private void assertAuthResultForNewAccount(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 57a9744..cd2dec7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -65,8 +65,9 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
 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;
@@ -87,6 +88,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
+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.client.ChangeKind;
@@ -135,8 +137,8 @@
 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.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -179,6 +181,8 @@
 
   @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
 
+  @Inject private AccountOperations accountOperations;
+
   private ChangeIndexedCounter changeIndexedCounter;
   private RegistrationHandle changeIndexedCounterHandle;
 
@@ -477,20 +481,40 @@
     assertThat(gApi.changes().id(changeId).get().pendingReviewers).isEmpty();
 
     // Add some pending reviewers.
-    TestAccount user1 =
-        accountCreator.create(name("user1"), name("user1") + "@example.com", "User 1");
-    TestAccount user2 =
-        accountCreator.create(name("user2"), name("user2") + "@example.com", "User 2");
-    TestAccount user3 =
-        accountCreator.create(name("user3"), name("user3") + "@example.com", "User 3");
-    TestAccount user4 =
-        accountCreator.create(name("user4"), name("user4") + "@example.com", "User 4");
+    String email1 = name("user1") + "@example.com";
+    String email2 = name("user2") + "@example.com";
+    String email3 = name("user3") + "@example.com";
+    String email4 = name("user4") + "@example.com";
+    accountOperations
+        .newAccount()
+        .username(name("user1"))
+        .preferredEmail(email1)
+        .fullname("User 1")
+        .create();
+    accountOperations
+        .newAccount()
+        .username(name("user2"))
+        .preferredEmail(email2)
+        .fullname("User 2")
+        .create();
+    accountOperations
+        .newAccount()
+        .username(name("user3"))
+        .preferredEmail(email3)
+        .fullname("User 3")
+        .create();
+    accountOperations
+        .newAccount()
+        .username(name("user4"))
+        .preferredEmail(email4)
+        .fullname("User 4")
+        .create();
     ReviewInput in =
         ReviewInput.noScore()
-            .reviewer(user1.email)
-            .reviewer(user2.email)
-            .reviewer(user3.email, CC, false)
-            .reviewer(user4.email, CC, false)
+            .reviewer(email1)
+            .reviewer(email2)
+            .reviewer(email3, CC, false)
+            .reviewer(email4, CC, false)
             .reviewer("byemail1@example.com")
             .reviewer("byemail2@example.com")
             .reviewer("byemail3@example.com", CC, false)
@@ -502,43 +526,43 @@
         ais -> ais.stream().map(ai -> ai.email).collect(toSet());
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
         .containsExactly(
-            admin.email, user1.email, user2.email, "byemail1@example.com", "byemail2@example.com");
+            admin.email, email1, email2, "byemail1@example.com", "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user3.email, user4.email, "byemail3@example.com", "byemail4@example.com");
+        .containsExactly(email3, email4, "byemail3@example.com", "byemail4@example.com");
     assertThat(info.pendingReviewers.get(REMOVED)).isNull();
 
     // Stage some pending reviewer removals.
-    gApi.changes().id(changeId).reviewer(user1.email).remove();
-    gApi.changes().id(changeId).reviewer(user3.email).remove();
+    gApi.changes().id(changeId).reviewer(email1).remove();
+    gApi.changes().id(changeId).reviewer(email3).remove();
     gApi.changes().id(changeId).reviewer("byemail1@example.com").remove();
     gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user2.email, "byemail2@example.com");
+        .containsExactly(admin.email, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
+        .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
-        .containsExactly(user1.email, user3.email, "byemail1@example.com", "byemail3@example.com");
+        .containsExactly(email1, email3, "byemail1@example.com", "byemail3@example.com");
 
     // "Undo" a removal.
-    in = ReviewInput.noScore().reviewer(user1.email);
+    in = ReviewInput.noScore().reviewer(email1);
     gApi.changes().id(changeId).revision("current").review(in);
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+        .containsExactly(admin.email, email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
+        .containsExactly(email4, "byemail4@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
-        .containsExactly(user3.email, "byemail1@example.com", "byemail3@example.com");
+        .containsExactly(email3, "byemail1@example.com", "byemail3@example.com");
 
     // "Commit" by moving out of WIP.
     gApi.changes().id(changeId).setReadyForReview();
     info = gApi.changes().id(changeId).get();
     assertThat(info.pendingReviewers).isEmpty();
     assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+        .containsExactly(admin.email, email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.reviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
+        .containsExactly(email4, "byemail4@example.com");
     assertThat(info.reviewers.get(REMOVED)).isNull();
   }
 
@@ -1315,10 +1339,11 @@
   public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
+      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     // admin pushes commit of user
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1388,10 +1413,11 @@
   public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
+      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     // admin pushes commit that references 'user' in a footer
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1431,10 +1457,11 @@
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
+      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     // create change
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1541,7 +1568,7 @@
 
     // Change status of reviewer and ensure ETag is updated.
     oldETag = rsrc.getETag();
-    gApi.accounts().id(user.id.get()).setStatus("new status");
+    accountOperations.account(user.id).forUpdate().status("new status").update();
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
@@ -1588,8 +1615,16 @@
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    //create a group named "ab" with one user: testUser
-    TestAccount testUser = accountCreator.create("abcd", "abcd@test.com", "abcd");
+    // create a group named "ab" with one user: testUser
+    String email = "abcd@test.com";
+    String fullname = "abcd";
+    TestAccount testUser =
+        accountOperations
+            .newAccount()
+            .username("abcd")
+            .preferredEmail(email)
+            .fullname(fullname)
+            .create();
     String testGroup = createGroupWithRealName("ab");
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
@@ -1602,11 +1637,11 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(testUser.emailAddress);
-    assertThat(m.body()).contains("Hello " + testUser.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(new Address(fullname, email));
+    assertThat(m.body()).contains("Hello " + fullname + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, testUser.email);
+    assertMailReplyTo(m, email);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     // When NoteDb is enabled adding a reviewer records that user as reviewer
@@ -1616,7 +1651,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.accountId().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1632,17 +1667,32 @@
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    //create a group named "kobe" with one user: lee
-    TestAccount testUser = accountCreator.create("kobebryant", "kobebryant@test.com", "kobebryant");
-    TestAccount myGroupUser = accountCreator.create("lee", "lee@test.com", "lee");
+    // create a group named "kobe" with one user: lee
+    String testUserFullname = "kobebryant";
+    accountOperations
+        .newAccount()
+        .username("kobebryant")
+        .preferredEmail("kobebryant@test.com")
+        .fullname(testUserFullname)
+        .create();
+
+    String myGroupUserEmail = "lee@test.com";
+    String myGroupUserFullname = "lee";
+    TestAccount myGroupUser =
+        accountOperations
+            .newAccount()
+            .username("lee")
+            .preferredEmail(myGroupUserEmail)
+            .fullname(myGroupUserFullname)
+            .create();
 
     String testGroup = createGroupWithRealName("kobe");
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
-    groupApi.addMembers(myGroupUser.fullName);
+    groupApi.addMembers(myGroupUserFullname);
 
-    //ensure that user "user" is not in the group
-    groupApi.removeMembers(testUser.fullName);
+    // ensure that user "user" is not in the group
+    groupApi.removeMembers(testUserFullname);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = testGroup;
@@ -1651,11 +1701,11 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(myGroupUser.emailAddress);
-    assertThat(m.body()).contains("Hello " + myGroupUser.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(new Address(myGroupUserFullname, myGroupUserEmail));
+    assertThat(m.body()).contains("Hello " + myGroupUserFullname + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, myGroupUser.email);
+    assertMailReplyTo(m, myGroupUserEmail);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     // When NoteDb is enabled adding a reviewer records that user as reviewer
@@ -1665,7 +1715,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.accountId().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1732,12 +1782,13 @@
 
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
-    TestAccount accountWithoutUsername = accountCreator.create();
+    com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
     assertThat(accountWithoutUsername.username).isNull();
     testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
   }
 
-  private void testImplicitlyCcOnNonVotingReviewPgStyle(TestAccount testAccount) throws Exception {
+  private void testImplicitlyCcOnNonVotingReviewPgStyle(
+      com.google.gerrit.acceptance.TestAccount testAccount) throws Exception {
     PushOneCommit.Result r = createChange();
     setApiUser(testAccount);
     assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
@@ -1762,12 +1813,13 @@
 
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNameGwtStyle() throws Exception {
-    TestAccount accountWithoutUsername = accountCreator.create();
+    com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
     assertThat(accountWithoutUsername.username).isNull();
     testImplicitlyCcOnNonVotingReviewGwtStyle(accountWithoutUsername);
   }
 
-  private void testImplicitlyCcOnNonVotingReviewGwtStyle(TestAccount testAccount) throws Exception {
+  private void testImplicitlyCcOnNonVotingReviewGwtStyle(
+      com.google.gerrit.acceptance.TestAccount testAccount) throws Exception {
     PushOneCommit.Result r = createChange();
     setApiUser(testAccount);
     assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
@@ -1918,16 +1970,21 @@
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType verified =
+          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      String heads = RefNames.REFS_HEADS + "*";
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(Util.verified().getName()),
+          -1,
+          1,
+          registeredUsers,
+          heads);
+      u.save();
+    }
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -2087,13 +2144,20 @@
     in.notify = NotifyHandling.NONE;
 
     // notify unrelated account as TO
-    TestAccount user2 = accountCreator.user2();
+    String email = "user2@example.com";
+    TestAccount user2 =
+        accountOperations
+            .newAccount()
+            .username("user2")
+            .preferredEmail(email)
+            .fullname("User2")
+            .create();
     setApiUser(user);
     recommend(r.getChangeId());
     setApiUser(admin);
     sender.clear();
     in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(user2.email)));
+    in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
     assertNotifyTo(user2);
 
@@ -2103,7 +2167,7 @@
     setApiUser(admin);
     sender.clear();
     in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(user2.email)));
+    in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
     assertNotifyCc(user2);
 
@@ -2113,7 +2177,7 @@
     setApiUser(admin);
     sender.clear();
     in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(user2.email)));
+    in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
     assertNotifyBcc(user2);
   }
@@ -2133,14 +2197,15 @@
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
         category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    String heads = "refs/heads/*";
-    AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, registered, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      String heads = "refs/heads/*";
+      AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
+      AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      Util.allow(u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, owners, heads);
+      Util.allow(u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, registered, heads);
+      u.save();
+    }
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
     PushOneCommit.Result r = createChange();
@@ -2406,16 +2471,17 @@
         category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
         category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    cfg.getLabelSections().put(custom1.getName(), custom1);
-    cfg.getLabelSections().put(custom2.getName(), custom2);
-    String heads = "refs/heads/*";
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel("Verified"), -1, 1, anon, heads);
-    Util.allow(cfg, Permission.forLabel("Custom1"), -1, 1, anon, heads);
-    Util.allow(cfg, Permission.forLabel("Custom2"), -1, 1, anon, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().getLabelSections().put(custom1.getName(), custom1);
+      u.getConfig().getLabelSections().put(custom2.getName(), custom2);
+      String heads = "refs/heads/*";
+      AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+      Util.allow(u.getConfig(), Permission.forLabel("Verified"), -1, 1, anon, heads);
+      Util.allow(u.getConfig(), Permission.forLabel("Custom1"), -1, 1, anon, heads);
+      Util.allow(u.getConfig(), Permission.forLabel("Custom2"), -1, 1, anon, heads);
+      u.save();
+    }
 
     PushOneCommit.Result r1 = createChange();
     r1.assertOkStatus();
@@ -2538,9 +2604,11 @@
     assertThat(approval._accountId).isEqualTo(user.id.get());
     assertThat(approval.value).isEqualTo(0);
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
+      u.save();
+    }
+
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
@@ -2869,13 +2937,16 @@
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
 
     // add new label and assert that it's returned for existing changes
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType verified = Util.verified();
-    cfg.getLabelSections().put(verified.getName(), verified);
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    LabelType verified = Util.verified();
     String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      Util.allow(
+          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+      u.save();
+    }
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -2889,11 +2960,13 @@
         .revision(r.getCommit().name())
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
 
-    // remove label and assert that it's no longer returned for existing
-    // changes, even if there is an approval for it
-    cfg.getLabelSections().remove(verified.getName());
-    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // remove label and assert that it's no longer returned for existing
+      // changes, even if there is an approval for it
+      u.getConfig().getLabelSections().remove(verified.getName());
+      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
+      u.save();
+    }
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -2920,14 +2993,17 @@
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 2);
 
-    // add new label and assert that it's returned for existing changes
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType verified = Util.verified();
-    cfg.getLabelSections().put(verified.getName(), verified);
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+
+    // add new label and assert that it's returned for existing changes
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      Util.allow(
+          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+      u.save();
+    }
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -2968,10 +3044,11 @@
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
-    cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().remove(verified.getName());
-    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().remove(verified.getName());
+      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
+      u.save();
+    }
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3008,13 +3085,20 @@
     push2.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
 
-    // Allow user to approve
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(
-        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+
+    // Allow user to approve
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(Util.codeReview().getName()),
+          -2,
+          2,
+          registeredUsers,
+          heads);
+      u.save();
+    }
 
     PushOneCommit.Result r = createChange();
 
@@ -3066,15 +3150,16 @@
     assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
     assertThat(approval.permittedVotingRange.max).isEqualTo(1);
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(
-        cfg,
-        Permission.forLabel("Code-Review"),
-        minPermittedValue,
-        maxPermittedValue,
-        REGISTERED_USERS,
-        heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel("Code-Review"),
+          minPermittedValue,
+          maxPermittedValue,
+          REGISTERED_USERS,
+          heads);
+      u.save();
+    }
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
@@ -3088,9 +3173,10 @@
 
   @Test
   public void maxPermittedValueBlocked() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
+      u.save();
+    }
 
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3106,6 +3192,58 @@
   }
 
   @Test
+  public void nonStrictLabelWithInvalidLabelPerDefault() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Add a review with invalid labels.
+    ReviewInput input = ReviewInput.approve().label("Code-Style", 1);
+    gApi.changes().id(changeId).current().review(input);
+
+    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    assertThat(votes.values()).containsExactly((short) 2);
+  }
+
+  @Test
+  public void nonStrictLabelWithInvalidValuePerDefault() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Add a review with invalid label values.
+    ReviewInput input = new ReviewInput().label("Code-Review", 3);
+    gApi.changes().id(changeId).current().review(input);
+
+    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
+    if (!notesMigration.readChanges()) {
+      assertThat(votes.keySet()).containsExactly("Code-Review");
+      assertThat(votes.values()).containsExactly((short) 0);
+    } else {
+      assertThat(votes).isEmpty();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
+  public void strictLabelWithInvalidLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Style", 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Code-Style\" is not a configured label");
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
+  public void strictLabelWithInvalidValue() throws Exception {
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Review", 3);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Code-Review\": 3 is not a valid value");
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  @Test
   public void unresolvedCommentsBlocked() throws Exception {
     modifySubmitRules(
         "submit_rule(submit(R)) :- \n"
@@ -3194,7 +3332,7 @@
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
 
-    for (TestAccount acc : ImmutableList.of(admin, user)) {
+    for (com.google.gerrit.acceptance.TestAccount acc : ImmutableList.of(admin, user)) {
       setApiUser(acc);
       String newMessage =
           "modified commit by " + acc.username + "\n\nChange-Id: " + r.getChangeId() + "\n";
@@ -3452,11 +3590,13 @@
 
   public void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = "Code-Review";
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    Util.allow(cfg, Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
+      u.save();
+    }
 
     setApiUser(user);
     PushOneCommit.Result r = createChange();
@@ -3480,11 +3620,14 @@
 
     setApiUser(admin);
     // Remove user's permission for 'Label'.
-    Util.remove(cfg, Permission.forLabel(label), registered, "refs/heads/*");
-    // Update user's permitted range for 'Code-Review' to be -1...+1.
-    Util.remove(cfg, Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.remove(u.getConfig(), Permission.forLabel(label), registered, "refs/heads/*");
+      // Update user's permitted range for 'Code-Review' to be -1...+1.
+      Util.remove(u.getConfig(), Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
+      u.save();
+    }
 
     // Verify user's new permitted range.
     setApiUser(user);
@@ -3651,7 +3794,14 @@
 
   @Test
   public void ignore() throws Exception {
-    TestAccount user2 = accountCreator.user2();
+    String email = "user2@example.com";
+    String fullname = "User2";
+    accountOperations
+        .newAccount()
+        .username("user2")
+        .preferredEmail(email)
+        .fullname(fullname)
+        .create();
 
     PushOneCommit.Result r = createChange();
 
@@ -3660,7 +3810,7 @@
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     in = new AddReviewerInput();
-    in.reviewer = user2.email;
+    in.reviewer = email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     setApiUser(user);
@@ -3672,7 +3822,7 @@
     gApi.changes().id(r.getChangeId()).abandon();
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    assertThat(messages.get(0).rcpt()).containsExactly(new Address(fullname, email));
 
     setApiUser(user);
     gApi.changes().id(r.getChangeId()).ignore(false);
@@ -3726,7 +3876,7 @@
 
   @Test
   public void markAsReviewed() throws Exception {
-    TestAccount user2 = accountCreator.user2();
+    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
 
     PushOneCommit.Result r = createChange();
 
@@ -3874,4 +4024,12 @@
       clear();
     }
   }
+
+  private PushOneCommit.Result createWorkInProgressChange() throws Exception {
+    return pushTo("refs/for/master%wip");
+  }
+
+  private BranchApi createBranch(String branch) throws Exception {
+    return createBranch(new Branch.NameKey(project, branch));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
new file mode 100644
index 0000000..f087b78
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.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.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+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.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Module;
+import java.util.ArrayList;
+import java.util.Collection;
+import org.junit.Test;
+
+public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
+  private static final SubmitRequirement req =
+      SubmitRequirement.builder()
+          .setType("custom_rule")
+          .setFallbackText("Fallback text")
+          .addCustomValue("key", "value")
+          .build();
+  private static final SubmitRequirementInfo reqInfo =
+      new SubmitRequirementInfo(
+          "NOT_READY", "Fallback text", "custom_rule", ImmutableMap.of("key", "value"));
+
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(SubmitRule.class)
+            .annotatedWith(Exports.named("CustomSubmitRule"))
+            .to(CustomSubmitRule.class);
+      }
+    };
+  }
+
+  @Test
+  public void checkSubmitRequirementIsPropagated() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo result = gApi.changes().id(r.getChangeId()).get();
+    assertThat(result.requirements).containsExactly(reqInfo);
+  }
+
+  private static class CustomSubmitRule implements SubmitRule {
+    @Override
+    public Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options) {
+      SubmitRecord record = new SubmitRecord();
+      record.labels = new ArrayList<>();
+      record.status = SubmitRecord.Status.NOT_READY;
+      record.requirements = ImmutableList.of(req);
+      return ImmutableList.of(record);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 4c97ac1..b23b2bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import java.util.EnumSet;
 import java.util.List;
@@ -59,34 +58,46 @@
 
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
+
   @Before
   public void setup() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType codeReview =
+          category(
+              "Code-Review",
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer that you didn't submit this"),
+              value(-2, "Do not submit"));
+      codeReview.setCopyAllScoresIfNoChange(false);
+      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
 
-    // Overwrite "Code-Review" label that is inherited from All-Projects.
-    // This way changes to the "Code Review" label don't affect other tests.
-    LabelType codeReview =
-        category(
-            "Code-Review",
-            value(2, "Looks good to me, approved"),
-            value(1, "Looks good to me, but someone else must approve"),
-            value(0, "No score"),
-            value(-1, "I would prefer that you didn't submit this"),
-            value(-2, "Do not submit"));
-    codeReview.setCopyAllScoresIfNoChange(false);
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+      LabelType verified =
+          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      verified.setCopyAllScoresIfNoChange(false);
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
 
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    verified.setCopyAllScoresIfNoChange(false);
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(
-        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
+      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      String heads = RefNames.REFS_HEADS + "*";
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(Util.codeReview().getName()),
+          -2,
+          2,
+          registeredUsers,
+          heads);
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(Util.verified().getName()),
+          -1,
+          1,
+          registeredUsers,
+          heads);
+      u.save();
+    }
   }
 
   @Test
@@ -97,9 +108,10 @@
 
   @Test
   public void stickyOnMinScore() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.save();
+    }
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -118,9 +130,10 @@
 
   @Test
   public void stickyOnMaxScore() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.save();
+    }
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -139,9 +152,10 @@
 
   @Test
   public void stickyOnTrivialRebase() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+      u.save();
+    }
 
     String changeId = createChange(TRIVIAL_REBASE);
     vote(admin, changeId, 2, 1);
@@ -184,9 +198,10 @@
 
   @Test
   public void stickyOnNoCodeChange() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.save();
+    }
 
     String changeId = createChange(NO_CODE_CHANGE);
     vote(admin, changeId, 2, 1);
@@ -207,9 +222,13 @@
 
   @Test
   public void stickyOnMergeFirstParentUpdate() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnMergeFirstParentUpdate(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getLabelSections()
+          .get("Code-Review")
+          .setCopyAllScoresOnMergeFirstParentUpdate(true);
+      u.save();
+    }
 
     String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
     vote(admin, changeId, 2, 1);
@@ -230,10 +249,11 @@
 
   @Test
   public void removedVotesNotSticky() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.save();
+    }
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -259,10 +279,11 @@
 
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.save();
+    }
 
     String changeId = createChange(REWORK);
     vote(admin, changeId, 2, 1);
@@ -280,10 +301,11 @@
 
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.save();
+    }
 
     // Vote max score on PS1
     String changeId = createChange(REWORK);
@@ -320,9 +342,10 @@
   @Test
   public void deleteStickyVote() throws Exception {
     String label = "Code-Review";
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get(label).setCopyMaxScore(true);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().get(label).setCopyMaxScore(true);
+      u.save();
+    }
 
     // Vote max score on PS1
     String changeId = createChange(REWORK);
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index c444fbd..4e3f048 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -28,14 +28,17 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.truth.Correspondence;
 import com.google.common.util.concurrent.AtomicLongMap;
 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.ProjectResetter;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -91,6 +94,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -121,6 +125,7 @@
   @Inject private PeriodicGroupIndexer slaveGroupIndexer;
   @Inject private DynamicSet<GroupIndexedListener> groupIndexedListeners;
   @Inject private Sequences seq;
+  @Inject private AccountOperations accountOperations;
 
   @Before
   public void setTimeForTesting() {
@@ -177,23 +182,25 @@
 
   @Test
   public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
-    TestAccount account = createUniqueAccount("user", "User");
+    String username = name("user");
+    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
+        accountOperations.newAccount().username(username).create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.getId());
+    groupIncludeCache.getGroupsWithMember(account.accountId());
     String groupName = createGroup("users");
     AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
 
-    gApi.groups().id(groupName).addMembers(account.username);
+    gApi.groups().id(groupName).addMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
-        groupIncludeCache.getGroupsWithMember(account.getId());
+        groupIncludeCache.getGroupsWithMember(account.accountId());
     assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
 
-    gApi.groups().id(groupName).removeMembers(account.username);
+    gApi.groups().id(groupName).removeMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
-        groupIncludeCache.getGroupsWithMember(account.getId());
+        groupIncludeCache.getGroupsWithMember(account.accountId());
     assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
   }
 
@@ -214,20 +221,54 @@
   @Test
   public void addMultipleMembers() throws Exception {
     String g = createGroup("users");
-    TestAccount u1 = createUniqueAccount("u1", "Full Name 1");
-    TestAccount u2 = createUniqueAccount("u2", "Full Name 2");
-    gApi.groups().id(g).addMembers(u1.username, u2.username);
-    assertMembers(g, u1, u2);
+
+    String u1 = name("u1");
+    accountOperations.newAccount().username(u1).create();
+    String u2 = name("u2");
+    accountOperations.newAccount().username(u2).create();
+
+    gApi.groups().id(g).addMembers(u1, u2);
+
+    List<AccountInfo> members = gApi.groups().id(g).members();
+    assertThat(members)
+        .comparingElementsUsing(getAccountToUsernameCorrespondence())
+        .containsExactly(u1, u2);
   }
 
   @Test
-  public void addMembersWithAtSign() throws Exception {
+  public void membersWithAtSignInUsernameCanBeAdded() throws Exception {
     String g = createGroup("users");
-    TestAccount u1 = createUniqueAccount("u1", "Full Name 1");
-    TestAccount u2_at = createUniqueAccount("u2@something", "Full Name 2 With At");
-    TestAccount u2 = createUniqueAccount("u2", "Full Name 2 Without At");
-    gApi.groups().id(g).addMembers(u1.username, u2_at.username, u2.username);
-    assertMembers(g, u1, u2_at, u2);
+    String usernameWithAt = name("u1@something");
+    accountOperations.newAccount().username(usernameWithAt).create();
+
+    gApi.groups().id(g).addMembers(usernameWithAt);
+
+    List<AccountInfo> members = gApi.groups().id(g).members();
+    assertThat(members)
+        .comparingElementsUsing(getAccountToUsernameCorrespondence())
+        .containsExactly(usernameWithAt);
+  }
+
+  @Test
+  public void membersWithAtSignInUsernameAreNotConfusedWithSimilarUsernames() throws Exception {
+    String g = createGroup("users");
+    String usernameWithAt = name("u1@something");
+    accountOperations.newAccount().username(usernameWithAt).create();
+    String usernameWithoutAt = name("u1something");
+    accountOperations.newAccount().username(usernameWithoutAt).create();
+    String usernameOnlyPrefix = name("u1");
+    accountOperations.newAccount().username(usernameOnlyPrefix).create();
+    String usernameOnlySuffix = name("something");
+    accountOperations.newAccount().username(usernameOnlySuffix).create();
+
+    gApi.groups()
+        .id(g)
+        .addMembers(usernameWithAt, usernameWithoutAt, usernameOnlyPrefix, usernameOnlySuffix);
+
+    List<AccountInfo> members = gApi.groups().id(g).members();
+    assertThat(members)
+        .comparingElementsUsing(getAccountToUsernameCorrespondence())
+        .containsExactly(usernameWithAt, usernameWithoutAt, usernameOnlyPrefix, usernameOnlySuffix);
   }
 
   @Test
@@ -370,17 +411,19 @@
 
   @Test
   public void cachedGroupsForMemberAreUpdatedOnGroupCreation() throws Exception {
-    TestAccount account = createUniqueAccount("user", "User");
+    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
+        accountOperations.newAccount().create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.id);
+    groupIncludeCache.getGroupsWithMember(account.accountId());
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("Users");
-    groupInput.members = ImmutableList.of(account.username);
+    groupInput.members = ImmutableList.of(String.valueOf(account.accountId().get()));
     GroupInfo group = gApi.groups().create(groupInput).get();
 
-    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(account.id);
+    Collection<AccountGroup.UUID> groups =
+        groupIncludeCache.getGroupsWithMember(account.accountId());
     assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
   }
 
@@ -622,28 +665,41 @@
   @Test
   public void listNonEmptyGroupMembers() throws Exception {
     String group = createGroup("group");
-    String user1 = createAccount("user1", group);
-    String user2 = createAccount("user2", group);
+    String user1 = name("user1");
+    accountOperations.newAccount().username(user1).create();
+    String user2 = name("user2");
+    accountOperations.newAccount().username(user2).create();
+    gApi.groups().id(group).addMembers(user1, user2);
+
     assertMembers(gApi.groups().id(group).members(), user1, user2);
   }
 
   @Test
   public void listOneGroupMember() throws Exception {
     String group = createGroup("group");
-    String user = createAccount("user1", group);
+    String user = name("user1");
+    accountOperations.newAccount().username(user).create();
+    gApi.groups().id(group).addMembers(user);
+
     assertMembers(gApi.groups().id(group).members(), user);
   }
 
   @Test
   public void listGroupMembersRecursively() throws Exception {
     String gx = createGroup("gx");
-    String ux = createAccount("ux", gx);
+    String ux = name("ux");
+    accountOperations.newAccount().username(ux).create();
+    gApi.groups().id(gx).addMembers(ux);
 
     String gy = createGroup("gy");
-    String uy = createAccount("uy", gy);
+    String uy = name("uy");
+    accountOperations.newAccount().username(uy).create();
+    gApi.groups().id(gy).addMembers(uy);
 
     String gz = createGroup("gz");
-    String uz = createAccount("uz", gz);
+    String uz = name("uz");
+    accountOperations.newAccount().username(uz).create();
+    gApi.groups().id(gz).addMembers(uz);
 
     gApi.groups().id(gx).addGroups(gy);
     gApi.groups().id(gy).addGroups(gz);
@@ -916,7 +972,7 @@
     // Verify "sub-group" has been deleted.
     try {
       gApi.groups().id(uuid.get()).get();
-      fail();
+      fail("expected ResourceNotFoundException");
     } catch (ResourceNotFoundException e) {
     }
   }
@@ -1050,8 +1106,9 @@
 
   @Test
   public void pushCustomInheritanceForAllUsersFails() throws Exception {
-    TestRepository<InMemoryRepository> repo = cloneProject(allUsers, RefNames.REFS_CONFIG);
-
+    TestRepository<InMemoryRepository> repo = cloneProject(allUsers);
+    GitUtil.fetch(repo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    repo.reset(RefNames.REFS_CONFIG);
     String config =
         gApi.projects()
             .name(allUsers.get())
@@ -1308,6 +1365,21 @@
     }
   }
 
+  private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
+    return new Correspondence<AccountInfo, String>() {
+      @Override
+      public boolean compare(AccountInfo actualAccount, String expectedName) {
+        String username = actualAccount == null ? null : actualAccount.username;
+        return Objects.equals(username, expectedName);
+      }
+
+      @Override
+      public String toString() {
+        return "has username";
+      }
+    };
+  }
+
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
     // Evict group from cache to be sure that we use the index state for staleness checks.
     groupCache.evict(groupUuid);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 4791d4c..2b1416a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -182,6 +183,19 @@
   }
 
   @Test
+  public void httpGet() throws Exception {
+    RestResponse rep =
+        adminRestSession.get(
+            "/projects/"
+                + normalProject.get()
+                + "/check.access"
+                + "?ref=refs/heads/master&perm=viewPrivateChanges&account="
+                + user.email);
+    rep.assertOK();
+    assertThat(rep.getEntityContent()).contains("403");
+  }
+
+  @Test
   public void accessible() throws Exception {
     List<TestCase> inputs =
         ImmutableList.of(
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 84cfb0d..b4a05fc 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -324,7 +324,7 @@
     ConfigInput input = createTestConfigInput();
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("write config not permitted");
+    exception.expectMessage("write refs/meta/config not permitted");
     gApi.projects().name(project.get()).config(input);
   }
 
@@ -359,7 +359,7 @@
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("set head not permitted");
+    exception.expectMessage("set HEAD not permitted for refs/heads/test");
     gApi.projects().name(project.get()).head("test");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 6a9b542..4c8f53f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -82,6 +83,11 @@
 
   @Before
   public void setUp() throws Exception {
+    // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
+    // computation, which might yield different results.)
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
+
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
@@ -1254,6 +1260,66 @@
   }
 
   @Test
+  public void intralineEditsInNonRebaseHunksAreIdentified() throws Exception {
+    assume().that(intraline).isTrue();
+
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 1\n", "Line one\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 1));
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(99);
+  }
+
+  @Test
+  public void intralineEditsInRebaseHunksAreIdentified() throws Exception {
+    assume().that(intraline).isTrue();
+
+    String newFileContent = FILE_CONTENT.replace("Line 1\n", "Line one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 1));
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    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);
+  }
+
+  @Test
   public void closeNonRebaseHunksAreCombinedForIntralineOptimizations() throws Exception {
     assume().that(intraline).isTrue();
 
@@ -1365,6 +1431,47 @@
     assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
   }
 
+  @Test
+  public void closeNonRebaseHunksNextToRebaseHunksAreCombinedForIntralineOptimizations()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n").replace("Line 7\n", "{\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = fileContent.replace("Line 8\n", "Line eight!\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
+    rebaseChangeOn(changeId, commit3);
+
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        content -> content.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line four", "{", "Line six");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    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);
+
+    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(2);
+  }
+
   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 1871343..3514e8e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -518,7 +518,7 @@
     in.message = r1.getCommit().getFullMessage();
     try {
       gApi.changes().id(t1).current().cherryPick(in);
-      fail();
+      fail("expected ResourceConflictException");
     } catch (ResourceConflictException e) {
       assertThat(e.getMessage())
           .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index f4bb2a9..91a1278 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -56,7 +56,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
@@ -601,11 +600,12 @@
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
     String cr = "Code-Review";
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReview = Util.codeReview();
-    codeReview.setCopyAllScoresIfNoCodeChange(true);
-    cfg.getLabelSections().put(cr, codeReview);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType codeReview = Util.codeReview();
+      codeReview.setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().getLabelSections().put(cr, codeReview);
+      u.save();
+    }
 
     ReviewInput r = new ReviewInput();
     r.labels = ImmutableMap.of(cr, (short) 1);
@@ -846,16 +846,18 @@
 
   private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     r.assertOK();
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, clazz);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
   }
 
   private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken) throws Exception {
     r.assertOK();
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, typeToken.getType());
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, typeToken.getType());
+    }
   }
 
   private String readContentFromJson(RestResponse r) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 2ae4133..df146c7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -85,7 +85,6 @@
 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.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
@@ -138,19 +137,25 @@
   }
 
   @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    patchSetLock = Util.patchSetLock();
-    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(
-        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(cfg);
+  public void setUpPatchSetLock() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      patchSetLock = Util.patchSetLock();
+      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(patchSetLock.getName()),
+          0,
+          1,
+          anonymousUsers,
+          "refs/heads/*");
+      u.save();
+    }
     grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
   }
 
   @After
-  public void tearDown() throws Exception {
+  public void resetPublishCommentOnPushOption() throws Exception {
     setApiUser(admin);
     GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
     prefs.publishCommentsOnPush = false;
@@ -932,12 +937,13 @@
   public void pushWithMultipleApprovals() throws Exception {
     LabelType Q =
         category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
     AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
     String heads = "refs/heads/*";
-    Util.allow(config, Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
-    config.getLabelSections().put(Q.getName(), Q);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
+      u.getConfig().getLabelSections().put(Q.getName(), Q);
+      u.save();
+    }
 
     RevCommit c =
         commitBuilder()
@@ -1116,7 +1122,7 @@
     r.assertOkStatus();
 
     setUseSignedOffBy(InheritableBoolean.TRUE);
-    blockForgeCommitter(project, "refs/heads/master");
+    block(project, "refs/heads/master", Permission.FORGE_COMMITTER, REGISTERED_USERS);
 
     push =
         pushFactory.create(
@@ -1213,12 +1219,14 @@
 
   @Test
   public void pushSameCommitTwice() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(
-            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.TRUE);
+      u.save();
+    }
 
     PushOneCommit push =
         pushFactory.create(
@@ -1240,12 +1248,14 @@
 
   @Test
   public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(
-            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.TRUE);
+      u.save();
+    }
 
     PushOneCommit push =
         pushFactory.create(
@@ -1456,11 +1466,12 @@
             + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
             + " commit");
 
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+      u.save();
+    }
 
     pushForReviewRejected(
         testRepo,
@@ -1481,11 +1492,12 @@
             + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
             + " commit");
 
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+      u.save();
+    }
 
     pushForReviewRejected(
         testRepo,
@@ -1646,11 +1658,12 @@
 
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReview = Util.codeReview();
-    codeReview.setCopyMaxScore(true);
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType codeReview = Util.codeReview();
+      codeReview.setCopyMaxScore(true);
+      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
+      u.save();
+    }
 
     PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review+2");
     r.assertOkStatus();
@@ -2239,11 +2252,16 @@
   private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
       throws Exception {
     // See SKIP_VALIDATION implementation in default permission backend.
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    Util.allow(config, Permission.FORGE_AUTHOR, groupUuid, ref);
-    Util.allow(config, Permission.FORGE_COMMITTER, groupUuid, ref);
-    Util.allow(config, Permission.FORGE_SERVER, groupUuid, ref);
-    Util.allow(config, Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.FORGE_AUTHOR, groupUuid, ref);
+      Util.allow(u.getConfig(), Permission.FORGE_COMMITTER, groupUuid, ref);
+      Util.allow(u.getConfig(), Permission.FORGE_SERVER, groupUuid, ref);
+      Util.allow(u.getConfig(), Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
+      u.save();
+    }
+  }
+
+  private PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
+    return amendChange(changeId, ref, admin, testRepo);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index 752da69..87ac022 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("non-fast forward");
+    r2.assertErrorStatus("need 'Force Push' privilege.");
   }
 
   @Test
@@ -94,7 +94,7 @@
 
   @Test
   public void deleteAllowedWithDeletePermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    grant(project, "refs/*", Permission.DELETE, true);
     assertDeleteRef(OK);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 72f2e0d..954ca8b 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.server.project.ProjectConfig;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -82,10 +81,12 @@
   }
 
   private void setRejectImplicitMerges() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getProject()
-        .setBooleanConfig(BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE);
+      u.save();
+    }
   }
 
   private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
new file mode 100644
index 0000000..b362a36
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -0,0 +1,384 @@
+// 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.git;
+
+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.gerrit.git.testing.PushResultSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import java.util.Arrays;
+import java.util.function.Consumer;
+import org.eclipse.jgit.api.PushCommand;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.TrackingRefUpdate;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PushPermissionsIT extends AbstractDaemonTest {
+  @Before
+  public void setUp() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      ProjectConfig cfg = u.getConfig();
+      cfg.getProject()
+          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+
+      // Remove push-related permissions, so they can be added back individually by test methods.
+      removeAllBranchPermissions(
+          cfg,
+          Permission.ADD_PATCH_SET,
+          Permission.CREATE,
+          Permission.DELETE,
+          Permission.PUSH,
+          Permission.PUSH_MERGE,
+          Permission.SUBMIT);
+      removeAllGlobalCapabilities(cfg, GlobalCapability.ADMINISTRATE_SERVER);
+
+      // Include some auxiliary permissions.
+      Util.allow(cfg, Permission.FORGE_AUTHOR, REGISTERED_USERS, "refs/*");
+      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
+
+      u.save();
+    }
+  }
+
+  @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");
+    assertThat(r)
+        .hasMessages(
+            "Branch refs/heads/master:",
+            "You are not allowed to perform this operation.",
+            "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");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  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());
+  }
+
+  @Test
+  public void deleteDenied() throws Exception {
+    PushResult r = push(":refs/heads/master");
+    assertThat(r).onlyRef("refs/heads/master").isRejected("cannot delete references");
+    assertThat(r)
+        .hasMessages(
+            "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");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void createDenied() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    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();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void groupRefsByMessage() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("foo").commit().create();
+      tr.branch("bar").commit().create();
+    }
+
+    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/master")
+        .isRejected("prohibited by Gerrit: ref update access denied");
+    assertThat(r)
+        .hasMessages(
+            "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.",
+            "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");
+  }
+
+  @Test
+  public void readOnlyProjectRejectedBeforeTestingPermissions() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+        u.save();
+      }
+    }
+
+    PushResult r = push(":refs/heads/master");
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: project state does not permit write");
+    assertThat(r).hasNoMessages();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void refsMetaConfigUpdateRequiresProjectOwner() throws Exception {
+    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
+
+    forceFetch("refs/meta/config");
+    ObjectId commit = testRepo.branch("refs/meta/config").commit().create();
+    PushResult r = push(commit.name() + ":refs/meta/config");
+    assertThat(r)
+        .onlyRef("refs/meta/config")
+        // 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");
+    assertThat(r)
+        .hasMessages(
+            "Branch refs/meta/config:",
+            "You are not allowed to perform this operation.",
+            "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");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+
+    // Re-fetch refs/meta/config from the server because the grant changed it, and we want a
+    // fast-forward.
+    forceFetch("refs/meta/config");
+    commit = testRepo.branch("refs/meta/config").commit().create();
+
+    assertThat(push(commit.name() + ":refs/meta/config")).onlyRef("refs/meta/config").isOk();
+  }
+
+  @Test
+  public void createChangeDenied() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/for/master");
+    assertThat(r)
+        .onlyRef("refs/for/master")
+        .isRejected("create change not permitted for 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");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void updateBySubmitDenied() throws Exception {
+    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+
+    ObjectId commit = testRepo.branch("HEAD").commit().create();
+    assertThat(push("HEAD:refs/for/master")).onlyRef("refs/for/master").isOk();
+    gApi.changes().id(commit.name()).current().review(ReviewInput.approve());
+
+    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();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void addPatchSetDenied() throws Exception {
+    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    setApiUser(user);
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.branch = "master";
+    ci.subject = "A change";
+    Change.Id id = new Change.Id(gApi.changes().create(ci).get()._number);
+
+    setApiUser(admin);
+    ObjectId ps1Id = forceFetch(new PatchSet.Id(id, 1).toRefName());
+    ObjectId ps2Id = testRepo.amend(ps1Id).add("file", "content").create();
+    PushResult r = push(ps2Id.name() + ":refs/for/master");
+    assertThat(r)
+        .onlyRef("refs/for/master")
+        .isRejected("cannot add patch set to " + id.get() + ".");
+    assertThat(r).hasNoMessages();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void skipValidationDenied() throws Exception {
+    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+
+    testRepo.branch("HEAD").commit().create();
+    PushResult r =
+        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();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void accessDatabaseForNoteDbDenied() throws Exception {
+    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+
+    testRepo.branch("HEAD").commit().create();
+    PushResult r =
+        push(
+            c -> c.setPushOptions(ImmutableList.of("notedb=allow")),
+            "HEAD:refs/changes/34/1234/meta");
+    // Same rejection message regardless of whether NoteDb is actually enabled.
+    assertThat(r)
+        .onlyRef("refs/changes/34/1234/meta")
+        .isRejected("NoteDb update requires access database permission");
+    assertThat(r).hasNoMessages();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
+  public void administrateServerForUpdateParentDenied() throws Exception {
+    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+
+    String project2 = name("project2");
+    gApi.projects().create(project2);
+
+    ObjectId oldId = forceFetch("refs/meta/config");
+
+    Config cfg = new BlobBasedConfig(null, testRepo.getRepository(), oldId, "project.config");
+    cfg.setString("access", null, "inheritFrom", project2);
+    ObjectId newId =
+        testRepo.branch("refs/meta/config").commit().add("project.config", cfg.toText()).create();
+
+    PushResult r = push(newId.name() + ":refs/meta/config");
+    assertThat(r)
+        .onlyRef("refs/meta/config")
+        .isRejected("invalid project configuration: only Gerrit admin can set parent");
+    assertThat(r).hasNoMessages();
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
+    cfg.getAccessSections()
+        .stream()
+        .filter(
+            s ->
+                s.getName().startsWith("refs/heads/")
+                    || s.getName().startsWith("refs/for/")
+                    || s.getName().equals("refs/*"))
+        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+  }
+
+  private static void removeAllGlobalCapabilities(ProjectConfig cfg, String... capabilities) {
+    Arrays.stream(capabilities)
+        .forEach(
+            c ->
+                cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+                    .getPermission(c, true)
+                    .getRules()
+                    .clear());
+  }
+
+  private PushResult push(String... refSpecs) throws Exception {
+    return push(c -> {}, refSpecs);
+  }
+
+  private PushResult push(Consumer<PushCommand> setUp, String... refSpecs) throws Exception {
+    PushCommand cmd =
+        testRepo
+            .git()
+            .push()
+            .setRemote("origin")
+            .setRefSpecs(Arrays.stream(refSpecs).map(RefSpec::new).collect(toList()));
+    setUp.accept(cmd);
+    Iterable<PushResult> results = cmd.call();
+    assertWithMessage("expected 1 PushResult").that(results).hasSize(1);
+    return results.iterator().next();
+  }
+
+  private ObjectId forceFetch(String ref) throws Exception {
+    TrackingRefUpdate u =
+        testRepo.git().fetch().setRefSpecs("+" + ref + ":" + ref).call().getTrackingRefUpdate(ref);
+    assertThat(u).isNotNull();
+    switch (u.getResult()) {
+      case NEW:
+      case FAST_FORWARD:
+      case FORCED:
+        break;
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case NO_CHANGE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      case RENAMED:
+      default:
+        assert_().fail("fetch failed to update local %s: %s", ref, u.getResult());
+        break;
+    }
+    return u.getNewObjectId();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index c64cdfb..45294fb 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -52,7 +52,6 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.NoteDbMode;
@@ -106,20 +105,22 @@
   private void setUpPermissions() throws Exception {
     // Remove read permissions for all users besides admin. This method is idempotent, so is safe
     // to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
+        sec.removePermission(Permission.READ);
+      }
+      Util.allow(u.getConfig(), Permission.READ, admins, "refs/*");
+      u.save();
     }
-    Util.allow(pc, Permission.READ, admins, "refs/*");
-    saveProjectConfig(allProjects, pc);
 
     // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
     // every test setup.
-    pc = projectCache.checkedGet(allUsers).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
+    try (ProjectConfigUpdate u = updateProject(allUsers)) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
+        sec.removePermission(Permission.READ);
+      }
+      u.save();
     }
-    saveProjectConfig(allUsers, pc);
   }
 
   private static String changeRefPrefix(Change.Id id) {
@@ -171,11 +172,12 @@
 
   @Test
   public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
-    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      Util.allow(u.getConfig(), Permission.READ, admins, RefNames.REFS_CONFIG);
+      Util.doNotInherit(u.getConfig(), Permission.READ, RefNames.REFS_CONFIG);
+      u.save();
+    }
 
     setApiUser(user);
     assertUploadPackRefs(
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index fac594a..5404fdd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -36,6 +36,7 @@
   public boolean viewConnections;
   public boolean viewPlugins;
   public boolean viewQueue;
+  public boolean viewAccess;
 
   static class QueryLimit {
     short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index a1dc8a2..f3fe68a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -15,19 +15,54 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.common.EmailInfo;
 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.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.DefaultRealm;
+import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.DisableReverseDnsLookup;
 import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.junit.Test;
 
 public class EmailIT extends AbstractDaemonTest {
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ExternalIds externalIds;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private AuthConfig authConfig;
+  @Inject private @AnonymousCowardName String anonymousCowardName;
+  @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
+  @Inject private @DisableReverseDnsLookup Boolean disableReverseDnsLookup;
+  @Inject private EmailExpander emailExpander;
+  @Inject private Provider<Emails> emails;
 
   @Test
   public void addEmail() throws Exception {
@@ -82,6 +117,143 @@
     assertThat(getEmails()).doesNotContain(email);
   }
 
+  @Test
+  public void setPreferredEmailToEmailOfMailToExternalId() throws Exception {
+    String email = "foo@example.com";
+    createEmail(email);
+    assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
+
+    resetCurrentApiUser();
+    gApi.accounts().self().email(email).setPreferred();
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+  }
+
+  @Test
+  public void setPreferredEmailToEmailOfExternalExternalId() throws Exception {
+    String email = "foo@example.com";
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Add External ID",
+            admin.id,
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(
+                        ExternalId.SCHEME_EXTERNAL, "foo", admin.id, email)));
+    assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
+
+    resetCurrentApiUser();
+    gApi.accounts().self().email(email).setPreferred();
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+  }
+
+  @Test
+  public void setPreferredEmailToNonExistingEmail() throws Exception {
+    String email = "non-existing@example.com";
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + email);
+    gApi.accounts().self().email(email).setPreferred();
+  }
+
+  @Test
+  public void setPreferredEmailToEmailOfOtherAccount() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + user.email);
+    gApi.accounts().self().email(user.email).setPreferred();
+  }
+
+  @Test
+  public void setPreferredEmailWithOtherCase() throws Exception {
+    String email = "foo@example.com";
+    createEmail(email);
+    assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
+
+    resetCurrentApiUser();
+    String emailOtherCase = email.toUpperCase();
+    gApi.accounts().self().email(emailOtherCase).setPreferred();
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+  }
+
+  @Test
+  public void setPreferredEmailToEmailFromCustomRealmThatDoesntExistAsExternalId()
+      throws Exception {
+    String email = "foo@example.com";
+    ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
+    assertThat(externalIds.get(mailtoExtIdKey)).isEmpty();
+    assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
+
+    Context oldCtx = createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id, email));
+    try {
+      gApi.accounts().self().email(email).setPreferred();
+      Optional<ExternalId> mailtoExtId = externalIds.get(mailtoExtIdKey);
+      assertThat(mailtoExtId).isPresent();
+      assertThat(mailtoExtId.get().accountId()).isEqualTo(admin.id);
+      assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+    } finally {
+      atrScope.set(oldCtx);
+    }
+  }
+
+  @Test
+  public void setPreferredEmailToEmailFromCustomRealmThatBelongsToOtherAccount() throws Exception {
+    ExternalId mailToExtId = ExternalId.createEmail(user.id, user.email);
+    assertThat(externalIds.get(mailToExtId.key())).isPresent();
+
+    Context oldCtx =
+        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id, user.email));
+    try {
+      exception.expect(ResourceConflictException.class);
+      exception.expectMessage("email in use by another account");
+      gApi.accounts().self().email(user.email).setPreferred();
+    } finally {
+      atrScope.set(oldCtx);
+    }
+  }
+
+  @Test
+  public void emailApi() throws Exception {
+    String email = "foo@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    // Create email
+    EmailInput emailInput = new EmailInput();
+    emailInput.email = email;
+    emailInput.noConfirmation = true;
+    gApi.accounts().self().createEmail(emailInput);
+    assertThat(getEmails()).contains(email);
+    assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
+
+    // Get email
+    resetCurrentApiUser();
+    EmailApi emailApi = gApi.accounts().self().email(email);
+    EmailInfo emailInfo = emailApi.get();
+    assertThat(emailInfo.email).isEqualTo(email);
+    assertThat(emailInfo.preferred).isNull();
+    assertThat(emailInfo.pendingConfirmation).isNull();
+
+    // Set as preferred email
+    emailApi.setPreferred();
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+
+    // Get email again (now it's the preferred email)
+    resetCurrentApiUser();
+    emailApi = gApi.accounts().self().email(email);
+    emailInfo = emailApi.get();
+    assertThat(emailInfo.email).isEqualTo(email);
+    assertThat(emailInfo.preferred).isTrue();
+    assertThat(emailInfo.pendingConfirmation).isNull();
+
+    // Delete email
+    emailApi.delete();
+    assertThat(getEmails()).doesNotContain(email);
+
+    // Now the email is no longer found
+    resetCurrentApiUser();
+    emailApi = gApi.accounts().self().email(email);
+    exception.expect(ResourceNotFoundException.class);
+    emailApi.get();
+  }
+
   private Set<String> getEmails() throws Exception {
     RestResponse r = adminRestSession.get("/accounts/self/emails");
     r.assertOK();
@@ -96,4 +268,38 @@
     RestResponse r = adminRestSession.put("/accounts/self/emails/" + email, input);
     r.assertCreated();
   }
+
+  private Context createContextWithCustomRealm(Realm realm) {
+    IdentifiedUser.GenericFactory userFactory =
+        new IdentifiedUser.GenericFactory(
+            authConfig,
+            realm,
+            anonymousCowardName,
+            canonicalUrl,
+            disableReverseDnsLookup,
+            accountCache,
+            groupBackend);
+    return atrScope.set(atrScope.newContext(reviewDbProvider, null, userFactory.create(admin.id)));
+  }
+
+  private class RealmWithAdditionalEmails extends DefaultRealm {
+    private final Multimap<Account.Id, String> additionalEmails;
+
+    public RealmWithAdditionalEmails(Account.Id accountId, String email) {
+      this(ImmutableMultimap.of(accountId, email));
+    }
+
+    public RealmWithAdditionalEmails(Multimap<Account.Id, String> additionalEmails) {
+      super(emailExpander, emails, authConfig);
+      this.additionalEmails = additionalEmails;
+    }
+
+    @Override
+    public boolean hasEmailAddress(IdentifiedUser user, String email) {
+      if (additionalEmails.containsEntry(user.getAccountId(), email)) {
+        return true;
+      }
+      return super.hasEmailAddress(user, email);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index f6e1e56..ab4ea40 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -64,6 +64,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -419,8 +420,8 @@
     assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
 
     for (ExternalId parseableExtId : parseableExtIds) {
-      ExternalId extId = externalIds.get(parseableExtId.key());
-      assertThat(extId).isEqualTo(parseableExtId);
+      Optional<ExternalId> extId = externalIds.get(parseableExtId.key());
+      assertThat(extId).hasValue(parseableExtId);
     }
   }
 
@@ -719,8 +720,8 @@
             "Create Account with Bad External ID",
             accountId,
             u -> u.addExternalId(ExternalId.create(extIdKey, accountId)));
-    ExternalId extId = externalIds.get(extIdKey);
-    assertThat(extId.accountId()).isEqualTo(accountId);
+    Optional<ExternalId> extId = externalIds.get(extIdKey);
+    assertThat(extId.map(ExternalId::accountId)).hasValue(accountId);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdNotesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdNotesIT.java
index 211ae18..60535c0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdNotesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdNotesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static org.hamcrest.CoreMatchers.instanceOf;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -25,6 +26,7 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -271,9 +273,9 @@
 
   private void assertExternalId(ExternalId.Key extIdKey, @Nullable String expectedEmail)
       throws Exception {
-    ExternalId extId = externalIds.get(extIdKey);
-    assertThat(extId).named(extIdKey.get()).isNotNull();
-    assertThat(extId.email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
+    Optional<ExternalId> extId = externalIds.get(extIdKey);
+    assertThat(extId).named(extIdKey.get()).isPresent();
+    assertThat(extId.get().email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
   }
 
   private void expectException(String message) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 8cfb8fa..e6f61fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -60,7 +60,6 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -139,26 +138,38 @@
   }
 
   @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
   public void voteOnBehalfOfInvalidLabel() throws Exception {
     allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
-    ReviewInput in = new ReviewInput();
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Not-A-Label", 5);
     in.onBehalfOf = user.id.toString();
-    in.label("Not-A-Label", 5);
 
     exception.expect(BadRequestException.class);
     exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    revision.review(in);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Review", 1).label("Not-A-Label", 5);
+    in.onBehalfOf = user.id.toString();
+    gApi.changes().id(changeId).current().review(in);
+
+    assertThat(gApi.changes().id(changeId).get().labels).doesNotContainKey("Not-A-Label");
   }
 
   @Test
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType verified = Util.verified();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType verified = Util.verified();
+      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.save();
+    }
 
     PushOneCommit.Result r = createChange();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
@@ -362,7 +373,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email;
     exception.expect(AuthException.class);
-    exception.expectMessage("submit as not permitted");
+    exception.expectMessage("submit on behalf of other users not permitted");
     gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
   }
 
@@ -539,44 +550,54 @@
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReviewType = Util.codeReview();
-    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-    String heads = "refs/heads/*";
-    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType codeReviewType = Util.codeReview();
+      String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+      String heads = "refs/heads/*";
+      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      Util.allow(u.getConfig(), forCodeReviewAs, -1, 1, uuid, heads);
+      u.save();
+    }
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    String heads = "refs/heads/*";
-    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
-    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
-    LabelType codeReviewType = Util.codeReview();
-    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      String heads = "refs/heads/*";
+      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      Util.allow(u.getConfig(), Permission.SUBMIT_AS, uuid, heads);
+      Util.allow(u.getConfig(), Permission.SUBMIT, uuid, heads);
+      LabelType codeReviewType = Util.codeReview();
+      Util.allow(u.getConfig(), Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
+      u.save();
+    }
   }
 
   private void blockRead(GroupInfo group) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.block(
+          u.getConfig(), Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
+      u.save();
+    }
   }
 
   private void allowRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.allow(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      Util.allow(
+          u.getConfig(),
+          GlobalCapability.RUN_AS,
+          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+      u.save();
+    }
   }
 
   private void removeRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.remove(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      Util.remove(
+          u.getConfig(),
+          GlobalCapability.RUN_AS,
+          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+      u.save();
+    }
   }
 
   private static Header runAsHeader(Object user) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index c9e04f7..507b74b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -71,7 +72,6 @@
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -203,7 +203,8 @@
     // change 2 is not approved, but we ignore labels
     approve(change3.getChangeId());
 
-    try (BinaryResult request = submitPreview(change4.getChangeId())) {
+    try (BinaryResult request =
+        gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
       assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
       submit(change4.getChangeId());
     } catch (RestApiException e) {
@@ -327,11 +328,13 @@
   public void noSelfSubmit() throws Exception {
     // create project where submit is blocked for the change owner
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.block(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.block(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
@@ -351,11 +354,13 @@
   public void onlySelfSubmit() throws Exception {
     // create project where only the change owner can submit
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.block(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.block(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/*");
+      Util.allow(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
@@ -525,7 +530,7 @@
 
   @Test
   public void submitWorkInProgressChange() throws Exception {
-    PushOneCommit.Result change = createWorkInProgressChange();
+    PushOneCommit.Result change = pushTo("refs/for/master%wip");
     Change.Id num = change.getChange().getId();
     submitWithConflict(
         change.getChangeId(),
@@ -567,12 +572,14 @@
     //  |
     // C0 -- Master
     //
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config
-        .getProject()
-        .setBooleanConfig(
-            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getProject()
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.TRUE);
+      u.save();
+    }
 
     PushOneCommit push1 =
         pushFactory.create(
@@ -912,7 +919,7 @@
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
     RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
-    RevCommit patchSet = parseCurrentRevision(rw, change);
+    RevCommit patchSet = parseCurrentRevision(rw, change.getChangeId());
     assertThat(rw.isMergedInto(patchSet, master)).isTrue();
 
     assertThat(input.generateLockFailures).containsExactly(false);
@@ -954,13 +961,13 @@
     repoA.git().fetch().call();
     RevWalk rwA = repoA.getRevWalk();
     RevCommit masterA = rwA.parseCommit(getRemoteHead(name("project-a"), "master"));
-    RevCommit change1Ps = parseCurrentRevision(rwA, change1);
+    RevCommit change1Ps = parseCurrentRevision(rwA, change1.getChangeId());
     assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
 
     repoB.git().fetch().call();
     RevWalk rwB = repoB.getRevWalk();
     RevCommit masterB = rwB.parseCommit(getRemoteHead(name("project-b"), "master"));
-    RevCommit change2Ps = parseCurrentRevision(rwB, change2);
+    RevCommit change2Ps = parseCurrentRevision(rwB, change2.getChangeId());
     assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
 
     assertThat(input.generateLockFailures).containsExactly(false);
@@ -1304,4 +1311,19 @@
       return out.toString();
     }
   }
+
+  private TestRepository<?> createProjectWithPush(
+      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
+    Project.NameKey project = createProject(name, parent, true, submitType);
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    return cloneProject(project);
+  }
+
+  protected PushOneCommit.Result createChange(
+      String subject, String fileName, String content, String topic) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master/" + name(topic));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index d86650b..0a92cfb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.TestSubmitInput;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -58,17 +57,18 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    Util.allow(
-        cfg,
-        Permission.forLabel(Util.codeReview().getName()),
-        -2,
-        2,
-        REGISTERED_USERS,
-        "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.block(u.getConfig(), Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
+      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(Util.codeReview().getName()),
+          -2,
+          2,
+          REGISTERED_USERS,
+          "refs/heads/*");
+      u.save();
+    }
 
     submitWithRebase(user);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 066af79..f89f2a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -71,6 +72,14 @@
     }
   }
 
+  protected Map<String, ActionInfo> getActions(String id) throws Exception {
+    return gApi.changes().id(id).revision(1).actions();
+  }
+
+  protected String getETag(String id) throws Exception {
+    return gApi.changes().id(id).current().etag();
+  }
+
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -452,4 +461,8 @@
     assertThat(actions).containsKey("description");
     assertThat(actions).containsKey("rebase");
   }
+
+  private PushOneCommit.Result createChangeWithTopic() throws Exception {
+    return createChangeWithTopic(testRepo, "topic", "message", "a.txt", "content\n");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index fbd55bb..59b6e29 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -47,7 +49,7 @@
         .containsExactly("master");
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
 
-    grantTagPermissions();
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
     gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index f1bba8a..ee5d3b0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -830,9 +830,10 @@
   private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
       throws Exception {
     r.assertStatus(expectedStatus);
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, clazz);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
   }
 
   private static void assertReviewers(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 30aeb69..baf56de 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -46,11 +45,12 @@
 public class ConfigChangeIT extends AbstractDaemonTest {
   @Before
   public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.OWNER, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.OWNER, REGISTERED_USERS, "refs/*");
+      Util.allow(u.getConfig(), Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
+      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
+      u.save();
+    }
 
     setApiUser(user);
     fetchRefsMetaConfig();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index ce949b4..6555fe8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -54,14 +53,15 @@
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
-        "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
+    try (ProjectConfigUpdate u = updateProject(p)) {
+      Util.allow(
+          u.getConfig(),
+          Permission.READ,
+          groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
+          "refs/*");
+      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     // Clone it and push a change as a regular user
     TestRepository<InMemoryRepository> repo = cloneProject(p, user);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 85093655..93ad2fe 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -34,7 +34,6 @@
 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.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -214,13 +213,19 @@
     Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType patchSetLock = Util.patchSetLock();
-    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(
-        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType patchSetLock = Util.patchSetLock();
+      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(patchSetLock.getName()),
+          0,
+          1,
+          registeredUsers,
+          "refs/heads/*");
+      u.save();
+    }
     grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
@@ -244,11 +249,15 @@
     configLabel(testLabelC, LabelFunction.NO_BLOCK);
 
     AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
+      u.save();
+    }
 
     String changeId = createChange().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.reject());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 2ebf3ca..ea8b98a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -122,7 +122,9 @@
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
 
     approve(change2.getChangeId());
-    Map<String, ActionInfo> actions = getActions(change2.getChangeId());
+
+    Map<String, ActionInfo> actions =
+        gApi.changes().id(change2.getChangeId()).revision(1).actions();
 
     assertThat(actions).containsKey("submit");
     ActionInfo info = actions.get("submit");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 19ca430..93b3e14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -265,7 +265,7 @@
               + "and upload the rebased commit for review.";
 
       // Get a preview before submitting:
-      try (BinaryResult r = submitPreview(change1b.getChangeId())) {
+      try (BinaryResult r = gApi.changes().id(change1b.getChangeId()).current().submitPreview()) {
         // We cannot just use the ExpectedException infrastructure as provided
         // by AbstractDaemonTest, as then we'd stop early and not test the
         // actual submit.
@@ -517,7 +517,8 @@
 
     // get a preview before submitting:
     File tempfile;
-    try (BinaryResult request = submitPreview(change1.getChangeId(), "tgz")) {
+    try (BinaryResult request =
+        gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
       assertThat(request.getContentType()).isEqualTo("application/x-gzip");
       tempfile = File.createTempFile("test", null);
       request.writeTo(Files.newOutputStream(tempfile.toPath()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index cea907d..95bc5a6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -54,7 +54,7 @@
   private TagType tagType;
 
   @Before
-  public void setup() throws Exception {
+  public void setUpTestEnvironment() throws Exception {
     // clone with user to avoid inherited tag permissions of admin user
     testRepo = cloneProject(project, user);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 87fd5cd..f7903dd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -156,6 +156,26 @@
   }
 
   @Test
+  public void createAccessChangeEmptyConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(Result.FORCED);
+
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSection = newAccessSectionInfo();
+      PermissionInfo read = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSection.permissions.put(Permission.READ, read);
+      accessInput.add.put(REFS_HEADS, accessSection);
+
+      ChangeInfo out = pApi().accessChange(accessInput);
+      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    }
+  }
+
+  @Test
   public void createAccessChange() throws Exception {
     allow(newProjectName, RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
     // User can see the branch
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
index 61f14e4..c0a413b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -46,7 +48,7 @@
     assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
     assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
 
-    grantTagPermissions();
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
     gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
 
     assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 6e1a184..0632221 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.server.project.ProjectConfig;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -116,9 +115,10 @@
   }
 
   private void unblockRead() throws Exception {
-    ProjectConfig pc = projectCache.checkedGet(project).getConfig();
-    pc.getAccessSection("refs/*").remove(new Permission(Permission.READ));
-    saveProjectConfig(project, pc);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getAccessSection("refs/*").remove(new Permission(Permission.READ));
+      u.save();
+    }
   }
 
   private void assertNotFound(ObjectId id) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index a5b0347..cd88a56 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import java.util.List;
 import java.util.Map;
@@ -55,9 +54,10 @@
     setApiUser(user);
     assertThatNameList(gApi.projects().list().get()).contains(project);
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
+      u.save();
+    }
 
     assertThatNameList(filter(gApi.projects().list().get())).doesNotContain(project);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index c9ba851..714751d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -370,4 +370,11 @@
       // Expected
     }
   }
+
+  private void grantTagPermissions() throws Exception {
+    grant(project, R_TAGS + "*", Permission.CREATE);
+    grant(project, R_TAGS + "", Permission.DELETE);
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index d60056a..14b3858 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import org.junit.After;
@@ -56,11 +55,24 @@
 
   @Before
   public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(label.getName()),
+          -1,
+          1,
+          anonymousUsers,
+          "refs/heads/*");
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(pLabel.getName()),
+          0,
+          1,
+          anonymousUsers,
+          "refs/heads/*");
+      u.save();
+    }
 
     eventListenerRegistration =
         source.add(
@@ -78,10 +90,11 @@
   }
 
   private void saveLabelConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(label.getName(), label);
-    cfg.getLabelSections().put(pLabel.getName(), pLabel);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(label.getName(), label);
+      u.getConfig().getLabelSections().put(pLabel.getName(), pLabel);
+      u.save();
+    }
   }
 
   /* Need to lookup info for the label under test since there can be multiple
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 112d982..7096581 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -866,7 +866,8 @@
   }
 
   @Test
-  public void addReviewerOnWipChangeAndStartReview() throws Exception {
+  public void addReviewerOnWipChangeAndStartReviewInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageWipChange();
     ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).revision("current").review(in);
@@ -877,6 +878,35 @@
         .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
         .noOneElse();
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(other)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerOnWipChangeAndStartReviewInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(other)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
     assertThat(sender).notSent();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 33d7b00..f5ff61f 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -75,7 +75,6 @@
 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.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.PostReview;
@@ -1574,17 +1573,23 @@
   }
 
   private void allowRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.allow(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      Util.allow(
+          u.getConfig(),
+          GlobalCapability.RUN_AS,
+          systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+      u.save();
+    }
   }
 
   private void removeRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.remove(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      Util.remove(
+          u.getConfig(),
+          GlobalCapability.RUN_AS,
+          systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+      u.save();
+    }
   }
 
   private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 8d3ce6d..0257240 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -43,7 +43,6 @@
 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 org.junit.After;
@@ -65,11 +64,19 @@
 
   @Before
   public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+      Util.allow(
+          u.getConfig(),
+          Permission.forLabel(label.getName()),
+          -1,
+          1,
+          anonymousUsers,
+          "refs/heads/*");
+      Util.allow(
+          u.getConfig(), Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
+      u.save();
+    }
 
     eventListenerRegistration =
         source.add(
@@ -288,10 +295,11 @@
         value(-1, "I would prefer this is not merged as is"),
         value(-2, "This shall not be merged"));
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    Util.allow(cfg, Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
+      u.save();
+    }
 
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -309,9 +317,11 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     // Update admin's permitted range for 'Test-Label' to be -1...+1.
-    Util.remove(cfg, Permission.forLabel(testLabel), registered, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
-    saveProjectConfig(cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.remove(u.getConfig(), Permission.forLabel(testLabel), registered, "refs/heads/*");
+      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
+      u.save();
+    }
 
     // Verify admin doesn't have +2 permission any more.
     assertPermitted(gApi.changes().id(changeId).get(), testLabel, -1, 0, 1);
@@ -336,10 +346,11 @@
   }
 
   private void saveLabelConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(label.getName(), label);
-    cfg.getLabelSections().put(P.getName(), P);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getLabelSections().put(label.getName(), label);
+      u.getConfig().getLabelSections().put(P.getName(), P);
+      u.save();
+    }
   }
 
   private ChangeInfo getWithLabels(PushOneCommit.Result r) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 0f81b5f..ca0cae4 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -31,7 +31,6 @@
 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.server.project.ProjectConfig;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import java.util.EnumSet;
 import java.util.List;
@@ -51,9 +50,10 @@
     nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
     nc.setFilter("message:sekret");
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("watch", nc);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().putNotifyConfig("watch", nc);
+      u.save();
+    }
 
     PushOneCommit.Result r =
         pushFactory
@@ -91,9 +91,10 @@
     nc.setHeader(NotifyConfig.Header.TO);
     nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().putNotifyConfig("team", nc);
+      u.save();
+    }
 
     sender.clear();
     PushOneCommit.Result r =
@@ -122,9 +123,10 @@
     nc.setHeader(NotifyConfig.Header.TO);
     nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().putNotifyConfig("team", nc);
+      u.save();
+    }
 
     PushOneCommit.Result r =
         pushFactory
@@ -152,9 +154,10 @@
     nc.setHeader(NotifyConfig.Header.TO);
     nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().putNotifyConfig("team", nc);
+      u.save();
+    }
 
     sender.clear();
     PushOneCommit.Result r =
@@ -182,9 +185,10 @@
     nc.setHeader(NotifyConfig.Header.TO);
     nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().putNotifyConfig("team", nc);
+      u.save();
+    }
 
     PushOneCommit.Result r =
         pushFactory
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ea42f47..9a5a899 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -26,8 +26,6 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.sshd.Commands;
-import com.jcraft.jsch.JSchException;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -54,6 +52,7 @@
           "ls-projects",
           "ls-user-refs",
           "plugin",
+          "reload-config",
           "show-caches",
           "show-connections",
           "show-queue",
@@ -95,7 +94,9 @@
                 }
               }),
           "index",
-          ImmutableList.of("activate", "changes", "project", "start"),
+          ImmutableList.of("changes", "project"), // "activate" and "start" are not included
+          "logging",
+          ImmutableList.of("ls", "set"),
           "plugin",
           ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"),
           "test-submit",
@@ -120,8 +121,7 @@
     testCommandExecution(SLAVE_COMMANDS);
   }
 
-  private void testCommandExecution(Map<String, List<String>> commands)
-      throws JSchException, IOException {
+  private void testCommandExecution(Map<String, List<String>> commands) throws Exception {
     for (String root : commands.keySet()) {
       for (String command : commands.get(root)) {
         // We can't assert that adminSshSession.hasError() is false, because using the --help
diff --git a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index aa7a987..95c585d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -121,7 +121,8 @@
       // Make sure the next one is not on the error channel
       packet = in.readString();
 
-      // 1 = DATA. It would be nicer to parse the OutputStream with SideBandInputStream from JGit, but
+      // 1 = DATA. It would be nicer to parse the OutputStream with SideBandInputStream from JGit,
+      // but
       // that is currently not public.
       char channel = packet.charAt(0);
       if (channel != 1) {
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index d906284..ff19646 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -30,5 +30,6 @@
         "//lib:guava",
         "//lib:truth",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
     ],
 )
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index 567595b..70d7089 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -41,7 +41,6 @@
         "//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:index-config",
         "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
index 794956f..6cfc583 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import java.util.concurrent.ExecutionException;
@@ -42,7 +42,6 @@
       return;
     }
     nodeInfo = ElasticTestUtils.startElasticsearchNode();
-    ElasticTestUtils.createAllIndexes(nodeInfo);
   }
 
   @AfterClass
@@ -54,11 +53,14 @@
     }
   }
 
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
   @After
   public void cleanupIndex() {
     if (nodeInfo != null) {
-      ElasticTestUtils.deleteAllIndexes(nodeInfo);
-      ElasticTestUtils.createAllIndexes(nodeInfo);
+      ElasticTestUtils.deleteAllIndexes(nodeInfo, testName());
     }
   }
 
@@ -66,7 +68,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.createAllIndexes(nodeInfo, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
index bc6c853..5949c06 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import java.util.concurrent.ExecutionException;
@@ -45,16 +45,6 @@
       return;
     }
     nodeInfo = ElasticTestUtils.startElasticsearchNode();
-
-    ElasticTestUtils.createAllIndexes(nodeInfo);
-  }
-
-  @After
-  public void cleanupIndex() {
-    if (nodeInfo != null) {
-      ElasticTestUtils.deleteAllIndexes(nodeInfo);
-      ElasticTestUtils.createAllIndexes(nodeInfo);
-    }
   }
 
   @AfterClass
@@ -66,11 +56,24 @@
     }
   }
 
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @After
+  public void cleanupIndex() {
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteAllIndexes(nodeInfo, testName());
+    }
+  }
+
   @Override
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.createAllIndexes(nodeInfo, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
index 9659c9e..a1c331d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import java.util.concurrent.ExecutionException;
@@ -42,7 +42,6 @@
       return;
     }
     nodeInfo = ElasticTestUtils.startElasticsearchNode();
-    ElasticTestUtils.createAllIndexes(nodeInfo);
   }
 
   @AfterClass
@@ -54,11 +53,14 @@
     }
   }
 
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
   @After
   public void cleanupIndex() {
     if (nodeInfo != null) {
-      ElasticTestUtils.deleteAllIndexes(nodeInfo);
-      ElasticTestUtils.createAllIndexes(nodeInfo);
+      ElasticTestUtils.deleteAllIndexes(nodeInfo, testName());
     }
   }
 
@@ -66,7 +68,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.createAllIndexes(nodeInfo, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
index 66a6aab..07fbf56 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import java.util.concurrent.ExecutionException;
@@ -42,7 +42,6 @@
       return;
     }
     nodeInfo = ElasticTestUtils.startElasticsearchNode();
-    ElasticTestUtils.createAllIndexes(nodeInfo);
   }
 
   @AfterClass
@@ -54,11 +53,14 @@
     }
   }
 
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
   @After
   public void cleanupIndex() {
     if (nodeInfo != null) {
-      ElasticTestUtils.deleteAllIndexes(nodeInfo);
-      ElasticTestUtils.createAllIndexes(nodeInfo);
+      ElasticTestUtils.deleteAllIndexes(nodeInfo, testName());
     }
   }
 
@@ -66,7 +68,9 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.createAllIndexes(nodeInfo, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index 1936707..ed21e6e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -70,11 +70,12 @@
     }
   }
 
-  static void configure(Config config, String port) {
+  static void configure(Config config, String port, String prefix) {
     config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
     config.setString("elasticsearch", "test", "protocol", "http");
     config.setString("elasticsearch", "test", "hostname", "localhost");
     config.setString("elasticsearch", "test", "port", port);
+    config.setString("elasticsearch", null, "prefix", prefix);
   }
 
   static ElasticNodeInfo startElasticsearchNode() throws InterruptedException, ExecutionException {
@@ -109,8 +110,46 @@
     return new ElasticNodeInfo(node, elasticDir, getHttpPort(node));
   }
 
-  static void deleteAllIndexes(ElasticNodeInfo nodeInfo) {
-    nodeInfo.node.client().admin().indices().prepareDelete("_all").execute().actionGet();
+  static void deleteAllIndexes(ElasticNodeInfo nodeInfo, String prefix) {
+    Schema<ChangeData> changeSchema = ChangeSchemaDefinitions.INSTANCE.getLatest();
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareDelete(String.format("%s%s_%04d", prefix, CHANGES, changeSchema.getVersion()))
+        .execute()
+        .actionGet();
+
+    Schema<AccountState> accountSchema = AccountSchemaDefinitions.INSTANCE.getLatest();
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareDelete(String.format("%s%s_%04d", prefix, ACCOUNTS, accountSchema.getVersion()))
+        .execute()
+        .actionGet();
+
+    Schema<InternalGroup> groupSchema = GroupSchemaDefinitions.INSTANCE.getLatest();
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareDelete(String.format("%s%s_%04d", prefix, GROUPS, groupSchema.getVersion()))
+        .execute()
+        .actionGet();
+
+    Schema<ProjectData> projectSchema = ProjectSchemaDefinitions.INSTANCE.getLatest();
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareDelete(String.format("%s%s_%04d", prefix, PROJECTS, projectSchema.getVersion()))
+        .execute()
+        .actionGet();
   }
 
   static class NodeInfo {
@@ -121,7 +160,7 @@
     Map<String, NodeInfo> nodes;
   }
 
-  static void createAllIndexes(ElasticNodeInfo nodeInfo) {
+  static void createAllIndexes(ElasticNodeInfo nodeInfo, String prefix) {
     Schema<ChangeData> changeSchema = ChangeSchemaDefinitions.INSTANCE.getLatest();
     ChangeMapping openChangesMapping = new ChangeMapping(changeSchema);
     ChangeMapping closedChangesMapping = new ChangeMapping(changeSchema);
@@ -132,7 +171,7 @@
         .client()
         .admin()
         .indices()
-        .prepareCreate(String.format("%s_%04d", CHANGES, changeSchema.getVersion()))
+        .prepareCreate(String.format("%s%s_%04d", prefix, CHANGES, changeSchema.getVersion()))
         .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
         .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
         .execute()
@@ -145,7 +184,7 @@
         .client()
         .admin()
         .indices()
-        .prepareCreate(String.format("%s_%04d", ACCOUNTS, accountSchema.getVersion()))
+        .prepareCreate(String.format("%s%s_%04d", prefix, ACCOUNTS, accountSchema.getVersion()))
         .addMapping(ElasticAccountIndex.ACCOUNTS, gson.toJson(accountMapping))
         .execute()
         .actionGet();
@@ -157,7 +196,7 @@
         .client()
         .admin()
         .indices()
-        .prepareCreate(String.format("%s_%04d", GROUPS, groupSchema.getVersion()))
+        .prepareCreate(String.format("%s%s_%04d", prefix, GROUPS, groupSchema.getVersion()))
         .addMapping(ElasticGroupIndex.GROUPS, gson.toJson(groupMapping))
         .execute()
         .actionGet();
@@ -169,7 +208,7 @@
         .client()
         .admin()
         .indices()
-        .prepareCreate(String.format("%s_%04d", PROJECTS, projectSchema.getVersion()))
+        .prepareCreate(String.format("%s%s_%04d", prefix, PROJECTS, projectSchema.getVersion()))
         .addMapping(ElasticProjectIndex.PROJECTS, gson.toJson(projectMapping))
         .execute()
         .actionGet();
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index b6ff156..117e474 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -34,7 +34,7 @@
   @Test
   public void containsWithEmpty() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    assertThat(ds.contains(2)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(2)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
@@ -42,7 +42,7 @@
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
 
-    assertThat(ds.contains(2)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(2)).isTrue(); // See above comment about ds.contains
   }
 
   @Test
@@ -50,7 +50,7 @@
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
 
-    assertThat(ds.contains(3)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
@@ -59,7 +59,7 @@
     ds.add(2);
     ds.add(4);
 
-    assertThat(ds.contains(4)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
   }
 
   @Test
@@ -68,7 +68,7 @@
     ds.add(2);
     ds.add(4);
 
-    assertThat(ds.contains(3)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
@@ -82,12 +82,12 @@
     ds.add(6);
 
     // At first, 4 is contained.
-    assertThat(ds.contains(4)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
 
     // Then we remove 4.
     handle.remove();
 
     // And now 4 should no longer be contained.
-    assertThat(ds.contains(4)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isFalse(); // See above comment about ds.contains
   }
 }
diff --git a/javatests/com/google/gerrit/git/testing/BUILD b/javatests/com/google/gerrit/git/testing/BUILD
new file mode 100644
index 0000000..13eb5bf
--- /dev/null
+++ b/javatests/com/google/gerrit/git/testing/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "testing_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/git/testing",
+        "//lib:truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
new file mode 100644
index 0000000..3bf815b
--- /dev/null
+++ b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.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.git.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.git.testing.PushResultSubject.parseProcessed;
+import static com.google.gerrit.git.testing.PushResultSubject.trimMessages;
+
+import org.junit.Test;
+
+public class PushResultSubjectTest {
+  @Test
+  public void testTrimMessages() {
+    assertThat(trimMessages(null)).isNull();
+    assertThat(trimMessages("")).isEqualTo("");
+    assertThat(trimMessages(" \n ")).isEqualTo("");
+    assertThat(trimMessages("\n Foo\nBar\n\nProcessing changes: 1, 2, 3 done   \n"))
+        .isEqualTo("Foo\nBar");
+  }
+
+  @Test
+  public void testParseProcessed() {
+    assertThat(parseProcessed(null)).isEmpty();
+    assertThat(parseProcessed("some other output")).isEmpty();
+    assertThat(parseProcessed("Processing changes: done\n")).isEmpty();
+    assertThat(parseProcessed("Processing changes: refs: 1, done \n")).containsExactly("refs", 1);
+    assertThat(parseProcessed("Processing changes: new: 1, updated: 2, refs: 3, done \n"))
+        .containsExactly("new", 1, "updated", 2, "refs", 3)
+        .inOrder();
+    assertThat(
+            parseProcessed(
+                "Some\nlonger\nmessage\nProcessing changes: new: 1\r"
+                    + "Processing changes: new: 1, updated: 1\r"
+                    + "Processing changes: new: 1, updated: 2, done"))
+        .containsExactly("new", 1, "updated", 2)
+        .inOrder();
+
+    // Atypical, but could potentially happen if there is an uncaught exception.
+    assertThat(parseProcessed("Processing changes: refs: 1")).containsExactly("refs", 1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 24d2822..2ddfaa9 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -55,6 +55,7 @@
         "//lib:gwtorm",
         "//lib:truth-java8-extension",
         "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 87e2e67..80a15a3 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -113,30 +113,30 @@
 
   @Test
   public void validity() throws Exception {
-    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, -1), KEY1);
-    assertThat(key.isValid()).isFalse();
-    key = new AccountSshKey(new AccountSshKey.Id(accountId, 0), KEY1);
-    assertThat(key.isValid()).isFalse();
-    key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY1);
-    assertThat(key.isValid()).isTrue();
+    AccountSshKey key = AccountSshKey.create(accountId, -1, KEY1);
+    assertThat(key.valid()).isFalse();
+    key = AccountSshKey.create(accountId, 0, KEY1);
+    assertThat(key.valid()).isFalse();
+    key = AccountSshKey.create(accountId, 1, KEY1);
+    assertThat(key.valid()).isTrue();
   }
 
   @Test
   public void getters() throws Exception {
-    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY1);
-    assertThat(key.getSshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.getAlgorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.getEncodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.getComment()).isEqualTo(KEY1.split(" ")[2]);
+    AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1);
+    assertThat(key.sshPublicKey()).isEqualTo(KEY1);
+    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
+    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
+    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
   }
 
   @Test
   public void keyWithNewLines() throws Exception {
-    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY1_WITH_NEWLINES);
-    assertThat(key.getSshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.getAlgorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.getEncodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.getComment()).isEqualTo(KEY1.split(" ")[2]);
+    AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1_WITH_NEWLINES);
+    assertThat(key.sshPublicKey()).isEqualTo(KEY1);
+    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
+    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
+    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
   }
 
   private static String toWindowsLineEndings(String s) {
@@ -157,8 +157,8 @@
     int seq = 1;
     for (Optional<AccountSshKey> sshKey : parsedKeys) {
       if (sshKey.isPresent()) {
-        assertThat(sshKey.get().getAccount()).isEqualTo(accountId);
-        assertThat(sshKey.get().getKey().get()).isEqualTo(seq);
+        assertThat(sshKey.get().accountId()).isEqualTo(accountId);
+        assertThat(sshKey.get().seq()).isEqualTo(seq);
       }
       seq++;
     }
@@ -170,10 +170,9 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey.Id keyId = new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
-    AccountSshKey key = new AccountSshKey(keyId, pub);
+    AccountSshKey key = AccountSshKey.create(new Account.Id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
-    return key.getSshPublicKey() + "\n";
+    return key.sshPublicKey() + "\n";
   }
 
   /**
@@ -182,11 +181,9 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addInvalidKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey.Id keyId = new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
-    AccountSshKey key = new AccountSshKey(keyId, pub);
-    key.setInvalid();
+    AccountSshKey key = AccountSshKey.createInvalid(new Account.Id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
-    return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.getSshPublicKey() + "\n";
+    return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.sshPublicKey() + "\n";
   }
 
   /**
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
new file mode 100644
index 0000000..6eca172
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = ["PerThreadCacheTest.java"],
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
new file mode 100644
index 0000000..ae5e911
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -0,0 +1,86 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.function.Supplier;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class PerThreadCacheTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void key_respectsClass() {
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isEqualTo(PerThreadCache.Key.create(String.class));
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isNotEqualTo(PerThreadCache.Key.create(Integer.class));
+  }
+
+  @Test
+  public void key_respectsIdentifiers() {
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
+  }
+
+  @Test
+  public void endToEndCache() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
+
+      String value1 = cache.get(key1, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+
+      Supplier<String> neverCalled =
+          () -> {
+            throw new IllegalStateException("this method must not be called");
+          };
+      assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
+    }
+  }
+
+  @Test
+  public void cleanUp() {
+    PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+    }
+
+    // Create a second cache and assert that it is not connected to the first one.
+    // This ensures that the cleanup is actually working.
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value2");
+      assertThat(value1).isEqualTo("value2");
+    }
+  }
+
+  @Test
+  public void doubleInstantiationFails() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      exception.expect(IllegalStateException.class);
+      exception.expectMessage("called create() twice on the same request");
+      PerThreadCache.create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 577d931..935dfc6 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -20,11 +20,15 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
 import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.google.inject.Singleton;
 import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
@@ -48,6 +52,7 @@
                         return "Print Hello";
                       }
                     });
+            bind(PermissionBackend.class).to(FakePermissionBackend.class);
           }
         };
     injector = Guice.createInjector(mod);
@@ -68,4 +73,27 @@
     assertThat(m.get(pluginCapability).id).isEqualTo(pluginCapability);
     assertThat(m.get(pluginCapability).name).isEqualTo("Print Hello");
   }
+
+  @Singleton
+  private static class FakePermissionBackend extends PermissionBackend {
+    @Override
+    public WithUser currentUser() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public WithUser user(CurrentUser user) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public WithUser absentUser(Id user) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean usesDefaultCapabilities() {
+      return true;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 5772a80..d242962 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -51,6 +51,11 @@
         }
 
         @Override
+        public Object getCacheKey() {
+          return new Object();
+        }
+
+        @Override
         public boolean isIdentifiedUser() {
           return true;
         }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 95ac3e3..8e8a0ea 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -93,7 +93,7 @@
   }
 
   @Test
-  public void storedSubmitRecordsWithRequirements() {
+  public void storedSubmitRecordsWithRequirement() {
     SubmitRecord r =
         record(
             SubmitRecord.Status.OK,
@@ -101,10 +101,27 @@
             label(SubmitRecord.Label.Status.OK, "Label-2", 1));
 
     SubmitRequirement sr =
-        new SubmitRequirement(
-            "short reason",
-            "Full reason can be a long string with special symbols like < > \\ / ; :",
-            null);
+        SubmitRequirement.builder()
+            .setType("short_type")
+            .setFallbackText("Fallback text may contain special symbols like < > \\ / ; :")
+            .addCustomValue("custom_data", "my value")
+            .build();
+    r.requirements = Collections.singletonList(sr);
+
+    assertStoredRecordRoundTrip(r);
+  }
+
+  @Test
+  public void storedSubmitRequirementWithoutCustomData() {
+    SubmitRecord r =
+        record(
+            SubmitRecord.Status.OK,
+            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1));
+
+    // Doesn't have any custom data value
+    SubmitRequirement sr =
+        SubmitRequirement.builder().setFallbackText("short_type").setType("ci_status").build();
     r.requirements = Collections.singletonList(sr);
 
     assertStoredRecordRoundTrip(r);
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 9cf013b..53994a6 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -70,7 +70,8 @@
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
-        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .containsExactly(
+            query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
         .inOrder();
   }
 
@@ -86,7 +87,8 @@
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
-        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .containsExactly(
+            query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
         .inOrder();
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 08ff40b..c677be5 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -123,7 +123,7 @@
   private String systemTimeZone;
 
   @Before
-  public void setUp() throws Exception {
+  public void setUpTestEnvironment() throws Exception {
     setTimeForTesting();
 
     serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index 2b660ed..ef80d7e 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -77,14 +77,14 @@
     String b = "multi\nlinemulti\nline\n";
     assertThat(intraline(a, b)).isEqualTo(wordEdit(10, 10, 6, 16));
     // better would be:
-    //assertThat(intraline(a, b)).isEqualTo(wordEdit(6, 6, 6, 16));
+    // assertThat(intraline(a, b)).isEqualTo(wordEdit(6, 6, 6, 16));
     // or the equivalent:
-    //assertThat(intraline(a, b)).isEqualTo(ref()
+    // assertThat(intraline(a, b)).isEqualTo(ref()
     //    .common("multi\n").insert("linemulti\n").common("line\n").edits
-    //);
+    // );
   }
 
-  //TODO: expected failure
+  // TODO: expected failure
   // the current code does not work on the first line
   // and the insert marker is in the wrong location
   @Test(expected = AssertionError.class)
@@ -95,7 +95,7 @@
         .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
   }
 
-  //TODO: expected failure
+  // TODO: expected failure
   // the current code does not work on the first line
   @Test(expected = AssertionError.class)
   public void preferDeleteAtLineBreak() throws Exception {
diff --git a/javatests/com/google/gerrit/server/permissions/RefPermissionTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
similarity index 71%
rename from javatests/com/google/gerrit/server/permissions/RefPermissionTest.java
rename to javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index f665fdc..305e81b 100644
--- a/javatests/com/google/gerrit/server/permissions/RefPermissionTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
 import com.google.gerrit.common.data.Permission;
 import org.junit.Test;
 
-public class RefPermissionTest {
+public class DefaultPermissionsMappingTest {
   @Test
-  public void fromName() {
-    assertThat(RefPermission.fromName("doesnotexist")).isEmpty();
-    assertThat(RefPermission.fromName("")).isEmpty();
-    assertThat(RefPermission.fromName(Permission.VIEW_PRIVATE_CHANGES))
+  public void stringToRefPermission() {
+    assertThat(refPermission("doesnotexist")).isEmpty();
+    assertThat(refPermission("")).isEmpty();
+    assertThat(refPermission(Permission.VIEW_PRIVATE_CHANGES))
         .hasValue(RefPermission.READ_PRIVATE_CHANGES);
   }
 }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 9722184..a14895b 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -37,6 +37,7 @@
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
@@ -258,6 +259,12 @@
 
           @Override
           public void evict(Project.NameKey p) {}
+
+          @Override
+          public ProjectState checkedGet(Project.NameKey projectName, boolean strict)
+              throws Exception {
+            return all.get(projectName);
+          }
         };
 
     Injector injector = Guice.createInjector(new InMemoryModule());
@@ -976,7 +983,8 @@
     return user(local, null, memberOf);
   }
 
-  private ProjectControl user(ProjectConfig local, String name, AccountGroup.UUID... memberOf) {
+  private ProjectControl user(
+      ProjectConfig local, @Nullable String name, AccountGroup.UUID... memberOf) {
     return new ProjectControl(
         Collections.<AccountGroup.UUID>emptySet(),
         Collections.<AccountGroup.UUID>emptySet(),
@@ -994,10 +1002,10 @@
   }
 
   private class MockUser extends CurrentUser {
-    private final String username;
+    @Nullable private final String username;
     private final GroupMembership groups;
 
-    MockUser(String name, AccountGroup.UUID[] groupId) {
+    MockUser(@Nullable String name, AccountGroup.UUID[] groupId) {
       username = name;
       ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
       groupIds.add(REGISTERED_USERS);
@@ -1011,8 +1019,13 @@
     }
 
     @Override
+    public Object getCacheKey() {
+      return new Object();
+    }
+
+    @Override
     public Optional<String> getUserName() {
-      return Optional.of(username);
+      return Optional.ofNullable(username);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/BUILD b/javatests/com/google/gerrit/server/query/BUILD
deleted file mode 100644
index 96201d2..0000000
--- a/javatests/com/google/gerrit/server/query/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "index-config",
-    srcs = glob(["*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 77b7729..d1c7477 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -601,8 +601,9 @@
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
     List<ByteArrayWrapper> blobs = new ArrayList<>();
     for (AccountExternalIdInfo info : externalIdInfos) {
-      blobs.add(
-          new ByteArrayWrapper(externalIds.get(ExternalId.Key.parse(info.identity)).toByteArray()));
+      Optional<ExternalId> extId = externalIds.get(ExternalId.Key.parse(info.identity));
+      assertThat(extId).isPresent();
+      blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
     assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
     assertThat(
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 497fc22..c352f43 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -35,7 +35,6 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query:index-config",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
diff --git a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index da4b0d5..660c1d8 100644
--- a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 1b56d60..525e030 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -18,6 +18,11 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.Util.allow;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.Util.verified;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -36,6 +41,8 @@
 import com.google.common.truth.ThrowableSubject;
 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.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -50,6 +57,7 @@
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -100,6 +108,7 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -173,6 +182,7 @@
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
   // Only for use in setting up/tearing down injector; other users should use schemaFactory.
   @Inject private InMemoryDatabase inMemoryDatabase;
@@ -366,6 +376,7 @@
     assertQuery("status:pe", expected);
     assertQuery("status:pen", expected);
     assertQuery("is:open", expected);
+    assertQuery("is:pending", expected);
   }
 
   @Test
@@ -621,13 +632,15 @@
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
-    insert(repo, ins);
+    Change change = insert(repo, ins);
     String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
+    assertQuery("commit:0000000000000000000000000000000000000000");
     for (int i = 0; i <= 36; i++) {
       String q = sha.substring(0, 40 - i);
-      assertQuery(q, ins.getChange());
+      assertQuery(q, change);
+      assertQuery("commit:" + q, change);
     }
   }
 
@@ -639,6 +652,7 @@
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
+    assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
     assertQuery("owner:" + user2, change2);
 
@@ -734,9 +748,14 @@
     Account.Id user2 =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
+    Change change3 = insert(repo, newChange(repo), user2);
+    gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(change3.getId().get()).current().submit();
 
     assertQuery("ownerin:Administrators", change1);
-    assertQuery("ownerin:\"Registered Users\"", change2, change1);
+    assertQuery("ownerin:\"Registered Users\"", change3, change2, change1);
+    assertQuery("ownerin:\"Registered Users\" project:repo", change3, change2, change1);
+    assertQuery("ownerin:\"Registered Users\" status:merged", change3);
   }
 
   @Test
@@ -753,6 +772,17 @@
   }
 
   @Test
+  public void byParentProject() 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("parentproject:repo1", change2, change1);
+    assertQuery("parentproject:repo2", change2);
+  }
+
+  @Test
   public void byProjectPrefix() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
@@ -939,6 +969,68 @@
   }
 
   @Test
+  public void byLabelMulti() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Project.NameKey project =
+        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    String heads = RefNames.REFS_HEADS + "*";
+    allow(cfg, Permission.forLabel(verified().getName()), -1, 1, REGISTERED_USERS, heads);
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+
+    ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins2 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins3 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins4 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins5 = newChange(repo, null, null, null, null, false);
+
+    // CR+1
+    Change reviewCRplus1 = insert(repo, ins);
+    gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
+
+    // CR+2
+    Change reviewCRplus2 = insert(repo, ins2);
+    gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
+
+    // CR+1 VR+1
+    Change reviewCRplus1VRplus1 = insert(repo, ins3);
+    gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
+    gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
+
+    // CR+2 VR+1
+    Change reviewCRplus2VRplus1 = insert(repo, ins4);
+    gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
+
+    // VR+1
+    Change reviewVRplus1 = insert(repo, ins5);
+    gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
+
+    assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
+    assertQuery(
+        "label:Code-Review>=+1",
+        reviewCRplus2VRplus1,
+        reviewCRplus1VRplus1,
+        reviewCRplus2,
+        reviewCRplus1);
+    assertQuery("label:Code-Review>=+2", reviewCRplus2VRplus1, reviewCRplus2);
+
+    assertQuery(
+        "label:Code-Review>=+1 label:Verified=+1", reviewCRplus2VRplus1, reviewCRplus1VRplus1);
+    assertQuery("label:Code-Review>=+2 label:Verified=+1", reviewCRplus2VRplus1);
+  }
+
+  @Test
   public void byLabelNotOwner() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo, null, null, null, null, false);
@@ -1288,7 +1380,7 @@
   }
 
   @Test
-  public void byBefore() throws Exception {
+  public void byBeforeUntil() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
@@ -1297,20 +1389,22 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
-    assertQuery("before:2009-09-29");
-    assertQuery("before:2009-09-30");
-    assertQuery("before:\"2009-09-30 16:59:00 -0400\"");
-    assertQuery("before:\"2009-09-30 20:59:00 -0000\"");
-    assertQuery("before:\"2009-09-30 20:59:00\"");
-    assertQuery("before:\"2009-09-30 17:02:00 -0400\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00 -0000\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00\"", change1);
-    assertQuery("before:2009-10-01", change1);
-    assertQuery("before:2009-10-03", change2, change1);
+    for (String predicate : Lists.newArrayList("before:", "until:")) {
+      assertQuery(predicate + "2009-09-29");
+      assertQuery(predicate + "2009-09-30");
+      assertQuery(predicate + "\"2009-09-30 16:59:00 -0400\"");
+      assertQuery(predicate + "\"2009-09-30 20:59:00 -0000\"");
+      assertQuery(predicate + "\"2009-09-30 20:59:00\"");
+      assertQuery(predicate + "\"2009-09-30 17:02:00 -0400\"", change1);
+      assertQuery(predicate + "\"2009-10-01 21:02:00 -0000\"", change1);
+      assertQuery(predicate + "\"2009-10-01 21:02:00\"", change1);
+      assertQuery(predicate + "2009-10-01", change1);
+      assertQuery(predicate + "2009-10-03", change2, change1);
+    }
   }
 
   @Test
-  public void byAfter() throws Exception {
+  public void byAfterSince() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
@@ -1319,11 +1413,13 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
-    assertQuery("after:2009-10-03");
-    assertQuery("after:\"2009-10-01 20:59:59 -0400\"", change2);
-    assertQuery("after:\"2009-10-01 20:59:59 -0000\"", change2);
-    assertQuery("after:2009-10-01", change2);
-    assertQuery("after:2009-09-30", change2, change1);
+    for (String predicate : Lists.newArrayList("after:", "since:")) {
+      assertQuery(predicate + "2009-10-03");
+      assertQuery(predicate + "\"2009-10-01 20:59:59 -0400\"", change2);
+      assertQuery(predicate + "\"2009-10-01 20:59:59 -0000\"", change2);
+      assertQuery(predicate + "2009-10-01", change2);
+      assertQuery(predicate + "2009-09-30", change2, change1);
+    }
   }
 
   @Test
@@ -1373,13 +1469,13 @@
 
     assertQuery("deleted:<=0", change1);
 
-    for (String str : Lists.newArrayList("delta", "size")) {
-      assertQuery(str + ":<2");
-      assertQuery(str + ":3", change1);
-      assertQuery(str + ":>2", change1);
-      assertQuery(str + ":>=3", change1);
-      assertQuery(str + ":<3", change2);
-      assertQuery(str + ":<=2", change2);
+    for (String str : Lists.newArrayList("delta:", "size:")) {
+      assertQuery(str + "<2");
+      assertQuery(str + "3", change1);
+      assertQuery(str + ">2", change1);
+      assertQuery(str + ">=3", change1);
+      assertQuery(str + "<3", change2);
+      assertQuery(str + "<=2", change2);
     }
   }
 
@@ -1489,6 +1585,28 @@
   }
 
   @Test
+  public void visible() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
+
+    String q = "project:repo";
+    assertQuery(q, change2, change1);
+
+    // Second user cannot see first user's private change.
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    assertQuery(q + " visibleto:" + user2.get(), change1);
+
+    requestContext.setContext(
+        newRequestContext(
+            accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
+    assertQuery("is:visible", change1);
+  }
+
+  @Test
   public void byCommentBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -1718,6 +1836,26 @@
   }
 
   @Test
+  public void mergeable() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("conflicts:" + change1.getId().get(), change2);
+    assertQuery("conflicts:" + change2.getId().get(), change1);
+    assertQuery("is:mergeable", change2, change1);
+
+    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+
+    assertQuery("status:open conflicts:" + change2.getId().get());
+    assertQuery("status:open is:mergeable");
+    assertQuery("status:open -is:mergeable", change2);
+  }
+
+  @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
@@ -1764,6 +1902,7 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
     AddReviewerInput rin = new AddReviewerInput();
@@ -1776,16 +1915,92 @@
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
 
+    assertQuery("is:reviewer");
+    assertQuery("reviewer:self");
+    gApi.changes().id(change3.getChangeId()).revision("current").review(ReviewInput.recommend());
+    assertQuery("is:reviewer", change3);
+    assertQuery("reviewer:self", change3);
+
+    requestContext.setContext(newRequestContext(user1));
     if (notesMigration.readChanges()) {
       assertQuery("reviewer:" + user1, change1);
       assertQuery("cc:" + user1, change2);
+      assertQuery("is:cc", change2);
+      assertQuery("cc:self", change2);
     } else {
       assertQuery("reviewer:" + user1, change2, change1);
       assertQuery("cc:" + user1);
+      assertQuery("is:cc");
+      assertQuery("cc:self");
     }
   }
 
   @Test
+  public void byReviewed() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherUser =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    assertQuery("is:reviewed");
+    assertQuery("status:reviewed");
+    assertQuery("-is:reviewed", change2, change1);
+    assertQuery("-status:reviewed", change2, change1);
+
+    requestContext.setContext(newRequestContext(otherUser));
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
+
+    assertQuery("is:reviewed", change1);
+    assertQuery("status:reviewed", change1);
+    assertQuery("-is:reviewed", change2);
+    assertQuery("-status:reviewed", change2);
+  }
+
+  @Test
+  public void reviewerin() throws Exception {
+    Account.Id user1 = accountManager.authenticate(AuthRequest.forUser("user1")).getAccountId();
+    Account.Id user2 = accountManager.authenticate(AuthRequest.forUser("user2")).getAccountId();
+    TestRepository<Repo> repo = createProject("repo");
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = user2.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    String group = gApi.groups().create("foo").get().name;
+    gApi.groups().id(group).addMembers(user2.toString());
+
+    List<String> members =
+        gApi.groups()
+            .id(group)
+            .members()
+            .stream()
+            .map(a -> a._accountId.toString())
+            .collect(toList());
+    assertThat(members).contains(user2.toString());
+
+    assertQuery("reviewerin:\"Registered Users\"", change2, change1);
+    assertQuery("reviewerin:" + group, change2);
+
+    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getId().get()).current().submit();
+
+    assertQuery("reviewerin:" + group, change2);
+    assertQuery("project:repo reviewerin:" + group, change2);
+    assertQuery("status:merged reviewerin:" + group, change2);
+  }
+
+  @Test
   public void reviewerAndCcByEmail() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
 
@@ -1899,6 +2114,10 @@
     // NEED records don't have associated users.
     assertQuery("label:CodE-RevieW=need,user1");
     assertQuery("label:CodE-RevieW=need,user");
+
+    gApi.changes().id(change1.getId().get()).current().submit();
+    assertQuery("submittable:ok");
+    assertQuery("submittable:closed", change1);
   }
 
   @Test
@@ -2207,6 +2426,28 @@
   }
 
   @Test
+  public void trackingid() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(repo.commit().message("Change two\n\nFeature:QUERY456").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("tr:QUERY123", change1);
+    assertQuery("bug:QUERY123", change1);
+    assertQuery("tr:QUERY456", change2);
+    assertQuery("bug:QUERY456", change2);
+    assertQuery("tr:QUERY-123");
+    assertQuery("bug:QUERY-123");
+    assertQuery("tr:QUERY12");
+    assertQuery("bug:QUERY12");
+    assertQuery("tr:QUERY789");
+    assertQuery("bug:QUERY789");
+  }
+
+  @Test
   public void selfAndMe() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -2553,6 +2794,89 @@
         mergedOwned);
   }
 
+  @Test
+  public void assignee() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    AssigneeInput input = new AssigneeInput();
+    input.assignee = user.getUserName().get();
+    gApi.changes().id(change1.getChangeId()).setAssignee(input);
+
+    assertQuery("is:assigned", change1);
+    assertQuery("-is:assigned", change2);
+    assertQuery("is:unassigned", change2);
+    assertQuery("-is:unassigned", change1);
+    assertQuery("assignee:" + user.getUserName().get(), change1);
+    assertQuery("-assignee:" + user.getUserName().get(), change2);
+  }
+
+  @Test
+  public void userDestination() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertThatQueryException("destination:foo")
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: foo");
+
+    String destination1 = "refs/heads/master\trepo1";
+    String destination2 = "refs/heads/master\trepo2";
+    String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
+    String destination4 = "refs/heads/master\trepo3";
+    String destination5 = "refs/heads/other\trepo1";
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+    String refsUsers = RefNames.refsUsers(userId);
+    allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination2", destination2).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination3", destination3).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
+
+    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+    assertThat(userRef).isNotNull();
+
+    assertQuery("destination:destination1", change1);
+    assertQuery("destination:destination2", change2);
+    assertQuery("destination:destination3", change2, change1);
+    assertQuery("destination:destination4");
+    assertQuery("destination:destination5");
+  }
+
+  @Test
+  public void userQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+
+    String queries =
+        "query1\tproject:repo\n"
+            + "query2\tproject:repo status:open\n"
+            + "query3\tproject:repo branch:stable\n"
+            + "query4\tproject:repo branch:other";
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+    String refsUsers = RefNames.refsUsers(userId);
+    allUsers.branch(refsUsers).commit().add("queries", queries).create();
+
+    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+    assertThat(userRef).isNotNull();
+
+    assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+
+    assertQuery("query:query1", change2, change1);
+    assertQuery("query:query2", change2, change1);
+    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    assertQuery("query:query2", change2);
+    assertQuery("query:query3", change2);
+    assertQuery("query:query4");
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
@@ -2678,6 +3002,14 @@
     return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
   }
 
+  protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+    ProjectInput input = new ProjectInput();
+    input.name = name;
+    input.parent = parent;
+    gApi.projects().create(input).get();
+    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.changes().query(query.toString());
   }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 5ade4ef..66c825c 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -16,6 +16,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//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/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gwtorm",
@@ -40,7 +41,6 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query:index-config",
         "//lib:gwtorm",
         "//lib:truth",
         "//lib/guice",
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index e0ddc4c..5ee3aa4 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -17,10 +17,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 7f14fe3..01a54a3 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -34,7 +34,6 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query:index-config",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
diff --git a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
index be231a3..83835c1 100644
--- a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.query.group;
 
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 4ad9e73..ac2692b 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -33,7 +33,6 @@
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server/query:index-config",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
diff --git a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
index 1cf09d8..42964fa 100644
--- a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.query.project;
 
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
-import com.google.gerrit.server.query.IndexConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
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 9cd57e0..6020325 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
@@ -23,10 +23,7 @@
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescription.Basic;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -52,7 +49,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
@@ -65,7 +61,7 @@
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gerrit.testing.TestTimeUtil.TempClockStep;
@@ -84,11 +80,9 @@
 import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -591,9 +585,9 @@
   public void logFormatWithExternalGroup() throws Exception {
     AccountGroup group = createInReviewDb("group");
 
-    backends.add(new TestGroupBackend());
-    AccountGroup.UUID subgroupUuid = TestGroupBackend.createUuuid("foo");
-
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+    backends.add(testGroupBackend);
+    AccountGroup.UUID subgroupUuid = testGroupBackend.create("test").getGroupUUID();
     assertThat(groupBackend.handles(subgroupUuid)).isTrue();
     addSubgroupsInReviewDb(group.getId(), subgroupUuid);
 
@@ -637,10 +631,10 @@
             "Update group\n"
                 + "\n"
                 + "Add-group: "
-                + TestGroupBackend.PREFIX
-                + "foo <"
-                + TestGroupBackend.PREFIX
-                + "foo>");
+                + subgroupUuid.get()
+                + " <"
+                + subgroupUuid.get()
+                + ">");
     assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
     assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
     assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
@@ -1128,77 +1122,4 @@
     groupInfo.options.visibleToAll = group.isVisibleToAll() ? true : null;
     return groupInfo;
   }
-
-  private static class TestGroupBackend implements GroupBackend {
-    static final String PREFIX = "testbackend:";
-
-    static AccountGroup.UUID createUuuid(String name) {
-      return new AccountGroup.UUID(PREFIX + name);
-    }
-
-    @Override
-    public Collection<GroupReference> suggest(String name, ProjectState project) {
-      return ImmutableSet.of();
-    }
-
-    @Override
-    public GroupMembership membershipsOf(IdentifiedUser user) {
-      return new GroupMembership() {
-        @Override
-        public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupIds) {
-          return ImmutableSet.of();
-        }
-
-        @Override
-        public Set<AccountGroup.UUID> getKnownGroups() {
-          return ImmutableSet.of();
-        }
-
-        @Override
-        public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
-          return false;
-        }
-
-        @Override
-        public boolean contains(AccountGroup.UUID groupId) {
-          return false;
-        }
-      };
-    }
-
-    @Override
-    public boolean isVisibleToAll(AccountGroup.UUID uuid) {
-      return false;
-    }
-
-    @Override
-    public boolean handles(AccountGroup.UUID uuid) {
-      return uuid.get().startsWith(PREFIX);
-    }
-
-    @Override
-    public Basic get(AccountGroup.UUID uuid) {
-      return new GroupDescription.Basic() {
-        @Override
-        public AccountGroup.UUID getGroupUUID() {
-          return uuid;
-        }
-
-        @Override
-        public String getName() {
-          return uuid.get().substring(PREFIX.length());
-        }
-
-        @Override
-        public String getEmailAddress() {
-          return null;
-        }
-
-        @Override
-        public String getUrl() {
-          return null;
-        }
-      };
-    }
-  }
 }
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 569398e..89adbde 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -1,13 +1,19 @@
 java_plugin(
     name = "auto-annotation-plugin",
     processor_class = "com.google.auto.value.processor.AutoAnnotationProcessor",
-    deps = ["@auto_value//jar"],
+    deps = [
+        "@auto_value//jar",
+        "@auto_value_annotations//jar",
+    ],
 )
 
 java_plugin(
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
-    deps = ["@auto_value//jar"],
+    deps = [
+        "@auto_value//jar",
+        "@auto_value_annotations//jar",
+    ],
 )
 
 java_library(
@@ -20,3 +26,14 @@
     visibility = ["//visibility:public"],
     exports = ["@auto_value//jar"],
 )
+
+java_library(
+    name = "auto-value-annotations",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-annotation-plugin",
+        ":auto-value-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto_value_annotations//jar"],
+)
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
index 18c62af..13c033e 100644
--- a/lib/elasticsearch/BUILD
+++ b/lib/elasticsearch/BUILD
@@ -7,7 +7,6 @@
     runtime_deps = [
         ":compress-lzf",
         ":hppc",
-        ":jna",
         ":joda-time",
         ":jsr166e",
         ":netty",
@@ -15,39 +14,16 @@
         "//lib/jackson:jackson-core",
         "//lib/jackson:jackson-dataformat-cbor",
         "//lib/jackson:jackson-dataformat-smile",
-        "//lib/lucene:lucene-codecs",
         "//lib/lucene:lucene-highlighter",
         "//lib/lucene:lucene-join",
         "//lib/lucene:lucene-memory",
         "//lib/lucene:lucene-queries",
-        "//lib/lucene:lucene-sandbox",
         "//lib/lucene:lucene-spatial",
         "//lib/lucene:lucene-suggest",
     ],
 )
 
 java_library(
-    name = "jest-common",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jest_common//jar"],
-)
-
-java_library(
-    name = "jest",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jest//jar"],
-    runtime_deps = [
-        ":elasticsearch",
-        ":jest-common",
-        "//lib/commons:lang3",
-        "//lib/httpcomponents:httpasyncclient",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore-nio",
-        "//lib/httpcomponents:httpcore-niossl",
-    ],
-)
-
-java_library(
     name = "joda-time",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@joda_time//jar"],
@@ -94,9 +70,3 @@
     visibility = ["//lib/elasticsearch:__pkg__"],
     exports = ["@t_digest//jar"],
 )
-
-java_library(
-    name = "jna",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jna//jar"],
-)
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 2b2cc6f..6e8fcd8 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -45,9 +45,3 @@
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@httpcore_nio//jar"],
 )
-
-java_library(
-    name = "httpcore-niossl",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@httpcore_niossl//jar"],
-)
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index 4847371..8ade0cf 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -9,13 +9,13 @@
 )
 
 java_library(
-    name = "jackson-dataformat-smile",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@jackson_dataformat_smile//jar"],
-)
-
-java_library(
     name = "jackson-dataformat-cbor",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@jackson_dataformat_cbor//jar"],
 )
+
+java_library(
+    name = "jackson-dataformat-smile",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@jackson_dataformat_smile//jar"],
+)
diff --git a/lib/jest/BUILD b/lib/jest/BUILD
new file mode 100644
index 0000000..169f271
--- /dev/null
+++ b/lib/jest/BUILD
@@ -0,0 +1,23 @@
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "jest-common",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jest_common//jar"],
+    runtime_deps = [
+        "//lib/commons:lang3",
+    ],
+)
+
+java_library(
+    name = "jest",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jest//jar"],
+    runtime_deps = [
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
+        "//lib/httpcomponents:httpcore-nio",
+    ],
+)
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index cf389850..749fec9 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,6 +1,6 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.11.0.201803080745-r.2-g61e4f1665"
+_JGIT_VERS = "4.11.0.201803080745-r.93-gcbb2e65db"
 
 _DOC_VERS = "4.11.0.201803080745-r"  # Set to _JGIT_VERS unless using a snapshot
 
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "38489eca0a4308087081d07774af86aa6a50b2ab",
-        src_sha1 = "e43c58829c72b5b18e16d1b2bbd1396ddd93098f",
+        sha1 = "265a39c017ecfeed7e992b6aaa336e515bf6e157",
+        src_sha1 = "e9d801e17afe71cdd5ade84ab41ff0110c3f28fd",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f5be45e4f97f0bf0825e4ff8fcb2f47588dd7e92",
+        sha1 = "0d68f62286b5db759fdbeb122c789db1f833a06a",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f202f169b2e3a50be90b4123baa941136eda3ed6",
+        sha1 = "4cc3ed2c42ee63593fd1b16215fcf13eeefb833e",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "dd9f7e4cc41b4f47591ce51c4752ccfef012c553",
+        sha1 = "6f1bcc9ac22b31b5a6e1e68c08283850108b900c",
         unsign = True,
     )
 
diff --git a/lib/js/BUILD b/lib/js/BUILD
index 8a7986e..706c472 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -24,6 +24,12 @@
 
 define_bower_components()
 
+js_component(
+    name = "highlightjs",
+    srcs = ["//lib/highlightjs:highlight.min.js"],
+    license = "//lib:LICENSE-highlightjs",
+)
+
 filegroup(
     name = "highlightjs_files",
     srcs = ["//lib/highlightjs:highlight.min.js"],
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index bbf43a6..6590af4 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -23,13 +23,6 @@
 )
 
 java_library(
-    name = "lucene-codecs",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@lucene_codecs//jar"],
-)
-
-java_library(
     name = "lucene-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -71,12 +64,6 @@
 )
 
 java_library(
-    name = "lucene-sandbox",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@lucene_sandbox//jar"],
-)
-
-java_library(
     name = "lucene-spatial",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@lucene_spatial//jar"],
diff --git a/plugins/replication b/plugins/replication
index a62d1c6..5e91925 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit a62d1c601e6c7fb669c847a0e1843e6f60cd1cb2
+Subproject commit 5e91925cfd391898e8e33fd149b9e1a115dafee4
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index b338cbf..fddfc57 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -11,6 +11,9 @@
         "//lib/js:ba-linkify",
         "//lib/js:es6-promise",
         "//lib/js:fetch",
+        # Although highlightjs is inserted separately in the UI zip, it's used
+        # by local development servers (e.g. --polygerrit-dev or run-server.sh).
+        "//lib/js:highlightjs",
         "//lib/js:iron-a11y-keys-behavior",
         "//lib/js:iron-autogrow-textarea",
         "//lib/js:iron-dropdown",
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 713b073..8f946c5 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -68,6 +68,12 @@
 PATH=$PATH:/usr/local/go/bin
 ```
 
+Install the go Soy template library:
+
+```
+go get "github.com/robfig/soy"
+```
+
 ### Running the server
 
 To test the local UI against gerrit-review.googlesource.com:
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 593a34b..c735746 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -15,7 +15,6 @@
         ],
         exclude = [
             "bower_components/**",
-            "index.html",
             "test/**",
             "**/*_test.html",
         ],
@@ -66,6 +65,8 @@
         ("tar -hcf- $(locations :pg_code) |" +
          " tar --strip-components=2 -C $$TMP/ -xf-"),
         "cd $$TMP",
+        "TZ=UTC",
+        "export TZ",
         "find . -exec touch -t 198001010000 '{}' ';'",
         "zip -rq $$ROOT/$@ *",
     ]),
@@ -158,7 +159,6 @@
         ],
         exclude = [
             "bower_components/**",
-            "index.html",
             "test/**",
             "**/*_test.html",
         ],
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
new file mode 100644
index 0000000..db11937
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
@@ -0,0 +1,206 @@
+<!--
+@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';
+
+  const ACCOUNT_CAPABILITIES = ['createProject', 'createGroup', 'viewPlugins'];
+
+  const ADMIN_LINKS = [{
+    name: 'Repositories',
+    noBaseUrl: true,
+    url: '/admin/repos',
+    view: 'gr-repo-list',
+    viewableToAll: true,
+  }, {
+    name: 'Groups',
+    section: 'Groups',
+    noBaseUrl: true,
+    url: '/admin/groups',
+    view: 'gr-admin-group-list',
+  }, {
+    name: 'Plugins',
+    capability: 'viewPlugins',
+    section: 'Plugins',
+    noBaseUrl: true,
+    url: '/admin/plugins',
+    view: 'gr-plugin-list',
+  }];
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.AdminNavBehavior */
+  Gerrit.AdminNavBehavior = {
+    /**
+     * @param {!Object} account
+     * @param {!Function} getAccountCapabilities
+     * @param {!Function} getAdminMenuLinks
+     *  Possible aguments in options:
+     *    repoName?: string
+     *    groupId?: string,
+     *    groupName?: string,
+     *    groupIsInternal?: boolean,
+     *    isAdmin?: boolean,
+     *    groupOwner?: boolean,
+     * @param {!Object=} opt_options
+     * @return {Promise<!Object>}
+     */
+    getAdminLinks(account, getAccountCapabilities, getAdminMenuLinks,
+        opt_options) {
+      if (!account) {
+        return Promise.resolve(this._filterLinks(link => link.viewableToAll,
+            getAdminMenuLinks, opt_options));
+      }
+      return getAccountCapabilities(ACCOUNT_CAPABILITIES)
+          .then(capabilities => {
+            return this._filterLinks(link => {
+              return !link.capability ||
+                  capabilities.hasOwnProperty(link.capability);
+            }, getAdminMenuLinks, opt_options);
+          });
+    },
+
+    /**
+     * @param {!Function} filterFn
+     * @param {!Function} getAdminMenuLinks
+     *  Possible aguments in options:
+     *    repoName?: string
+     *    groupId?: string,
+     *    groupName?: string,
+     *    groupIsInternal?: boolean,
+     *    isAdmin?: boolean,
+     *    groupOwner?: boolean,
+     * @param {!Object|undefined} opt_options
+     * @return {Promise<!Object>}
+     */
+    _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
+      let links = ADMIN_LINKS.slice(0);
+      let expandedSection;
+
+      const isExernalLink = link => link.url[0] !== '/';
+
+      // Append top-level links that are defined by plugins.
+      links.push(...getAdminMenuLinks().map(link => ({
+        url: link.url,
+        name: link.text,
+        noBaseUrl: !isExernalLink(link),
+        view: null,
+        viewableToAll: true,
+        target: isExernalLink(link) ? '_blank' : null,
+      })));
+
+      links = links.filter(filterFn);
+
+      const filteredLinks = [];
+      const repoName = opt_options && opt_options.repoName;
+      const groupId = opt_options && opt_options.groupId;
+      const groupName = opt_options && opt_options.groupName;
+      const groupIsInternal = opt_options && opt_options.groupIsInternal;
+      const isAdmin = opt_options && opt_options.isAdmin;
+      const groupOwner = opt_options && opt_options.groupOwner;
+
+      // Don't bother to get sub-navigation items if only the top level links
+      // are needed. This is used by the main header dropdown.
+      if (!repoName && !groupId) { return {links, expandedSection}; }
+
+      // Otherwise determine the full set of links and return both the full
+      // set in addition to the subsection that should be displayed if it
+      // exists.
+      for (const link of links) {
+        const linkCopy = Object.assign({}, link);
+        if (linkCopy.name === 'Repositories' && repoName) {
+          linkCopy.subsection = this.getRepoSubsections(repoName);
+          expandedSection = linkCopy.subsection;
+        } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+          linkCopy.subsection = this.getGroupSubsections(groupId, groupName,
+              groupIsInternal, isAdmin, groupOwner);
+          expandedSection = linkCopy.subsection;
+        }
+        filteredLinks.push(linkCopy);
+      }
+      return {links: filteredLinks, expandedSection};
+    },
+
+    getGroupSubsections(groupId, groupName, groupIsInternal, isAdmin,
+        groupOwner) {
+      const subsection = {
+        name: groupName,
+        view: Gerrit.Nav.View.GROUP,
+        url: Gerrit.Nav.getUrlForGroup(groupId),
+        children: [],
+      };
+      if (groupIsInternal) {
+        subsection.children.push({
+          name: 'Members',
+          detailType: Gerrit.Nav.GroupDetailView.MEMBERS,
+          view: Gerrit.Nav.View.GROUP,
+          url: Gerrit.Nav.getUrlForGroupMembers(groupId),
+        });
+      }
+      if (groupIsInternal && (isAdmin || groupOwner)) {
+        subsection.children.push(
+            {
+              name: 'Audit Log',
+              detailType: Gerrit.Nav.GroupDetailView.LOG,
+              view: Gerrit.Nav.View.GROUP,
+              url: Gerrit.Nav.getUrlForGroupLog(groupId),
+            }
+        );
+      }
+      return subsection;
+    },
+
+    getRepoSubsections(repoName) {
+      return {
+        name: repoName,
+        view: Gerrit.Nav.View.REPO,
+        url: Gerrit.Nav.getUrlForRepo(repoName),
+        children: [{
+          name: 'Access',
+          view: Gerrit.Nav.View.REPO,
+          detailType: Gerrit.Nav.RepoDetailView.ACCESS,
+          url: Gerrit.Nav.getUrlForRepoAccess(repoName),
+        },
+        {
+          name: 'Commands',
+          view: Gerrit.Nav.View.REPO,
+          detailType: Gerrit.Nav.RepoDetailView.COMMANDS,
+          url: Gerrit.Nav.getUrlForRepoCommands(repoName),
+        },
+        {
+          name: 'Branches',
+          view: Gerrit.Nav.View.REPO,
+          detailType: Gerrit.Nav.RepoDetailView.BRANCHES,
+          url: Gerrit.Nav.getUrlForRepoBranches(repoName),
+        },
+        {
+          name: 'Tags',
+          view: Gerrit.Nav.View.REPO,
+          detailType: Gerrit.Nav.RepoDetailView.TAGS,
+          url: Gerrit.Nav.getUrlForRepoTags(repoName),
+        },
+        {
+          name: 'Dashboards',
+          view: Gerrit.Nav.View.REPO,
+          detailType: Gerrit.Nav.RepoDetailView.DASHBOARDS,
+          url: Gerrit.Nav.getUrlForRepoDashboards(repoName),
+        }],
+      };
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
new file mode 100644
index 0000000..a1902cd
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
@@ -0,0 +1,311 @@
+<!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>keyboard-shortcut-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-admin-nav-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-admin-nav-behavior tests', () => {
+    let element;
+    let sandbox;
+    let capabilityStub;
+    let menuLinkStub;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [
+          Gerrit.AdminNavBehavior,
+        ],
+      });
+    });
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      capabilityStub = sinon.stub();
+      menuLinkStub = sinon.stub().returns([]);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    const testAdminLinks = (account, options, expected, done) => {
+      element.getAdminLinks(account,
+          capabilityStub,
+          menuLinkStub,
+          options)
+          .then(res => {
+            assert.equal(expected.totalLength, res.links.length);
+            assert.equal(res.links[0].name, 'Repositories');
+            // Repos
+            if (expected.groupListShown) {
+              assert.equal(res.links[1].name, 'Groups');
+            }
+
+            if (expected.pluginListShown) {
+              assert.equal(res.links[2].name, 'Plugins');
+              assert.isNotOk(res.links[2].subsection);
+            }
+
+            if (expected.projectPageShown) {
+              assert.isOk(res.links[0].subsection);
+              assert.equal(res.links[0].subsection.children.length, 5);
+            } else {
+              assert.isNotOk(res.links[0].subsection);
+            }
+            // Groups
+            if (expected.groupPageShown) {
+              assert.isOk(res.links[1].subsection);
+              assert.equal(res.links[1].subsection.children.length,
+                  expected.groupSubpageLength);
+            } else if ( expected.totalLength > 1) {
+              assert.isNotOk(res.links[1].subsection);
+            }
+
+            if (expected.pluginGeneratedLinks) {
+              for (const link of expected.pluginGeneratedLinks) {
+                const linkMatch = res.links.find(l => {
+                  return (l.url === link.url && l.name === link.text);
+                });
+                assert.isTrue(!!linkMatch);
+
+                // External links should open in new tab.
+                if (link.url[0] !== '/') {
+                  assert.equal(linkMatch.target, '_blank');
+                } else {
+                  assert.isNotOk(linkMatch.target);
+                }
+              }
+            }
+
+            // Current section
+            if (expected.projectPageShown || expected.groupPageShown) {
+              assert.isOk(res.expandedSection);
+              assert.isOk(res.expandedSection.children);
+            } else {
+              assert.isNotOk(res.expandedSection);
+            }
+            if (expected.projectPageShown) {
+              assert.equal(res.expandedSection.name, 'my-repo');
+              assert.equal(res.expandedSection.children.length, 5);
+            } else if (expected.groupPageShown) {
+              assert.equal(res.expandedSection.name, 'my-group');
+              assert.equal(res.expandedSection.children.length,
+                  expected.groupSubpageLength);
+            }
+            done();
+          });
+    };
+
+    suite('logged out', () => {
+      let account;
+      let expected;
+
+      setup(() => {
+        expected = {
+          groupListShown: false,
+          groupPageShown: false,
+          pluginListShown: false,
+        };
+      });
+
+      test('without a specific repo or group', done => {
+        let options;
+        expected = Object.assign(expected, {
+          totalLength: 1,
+          projectPageShown: false,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('with a repo', done => {
+        const options = {repoName: 'my-repo'};
+        expected = Object.assign(expected, {
+          totalLength: 1,
+          projectPageShown: true,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('with plugin generated links', done => {
+        let options;
+        const generatedLinks = [
+          {text: 'internal link text', url: '/internal/link/url'},
+          {text: 'external link text', url: 'http://external/link/url'},
+        ];
+        menuLinkStub.returns(generatedLinks);
+        expected = Object.assign(expected, {
+          totalLength: 3,
+          projectPageShown: false,
+          pluginGeneratedLinks: generatedLinks,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+    });
+
+    suite('no plugin capability logged in', () => {
+      const account = {
+        name: 'test-user',
+      };
+      let expected;
+
+      setup(() => {
+        expected = {
+          totalLength: 2,
+          pluginListShown: false,
+        };
+        capabilityStub.returns(Promise.resolve({}));
+      });
+
+      test('without a specific project or group', done => {
+        let options;
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupListShown: true,
+          groupPageShown: false,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('with a repo', done => {
+        const account = {
+          name: 'test-user',
+        };
+        const options = {repoName: 'my-repo'};
+        expected = Object.assign(expected, {
+          projectPageShown: true,
+          groupListShown: true,
+          groupPageShown: false,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+    });
+
+    suite('view plugin capability logged in', () => {
+      const account = {
+        name: 'test-user',
+      };
+      let expected;
+
+      setup(() => {
+        capabilityStub.returns(Promise.resolve({viewPlugins: true}));
+        expected = {
+          totalLength: 3,
+          groupListShown: true,
+          pluginListShown: true,
+        };
+      });
+
+      test('without a specific repo or group', done => {
+        let options;
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupPageShown: false,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('with a repo', done => {
+        const options = {repoName: 'my-repo'};
+        expected = Object.assign(expected, {
+          projectPageShown: true,
+          groupPageShown: false,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('admin with internal group', done => {
+        const options = {
+          groupId: 'a15262',
+          groupName: 'my-group',
+          groupIsInternal: true,
+          isAdmin: true,
+          groupOwner: false,
+        };
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupPageShown: true,
+          groupSubpageLength: 2,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('group owner with internal group', done => {
+        const options = {
+          groupId: 'a15262',
+          groupName: 'my-group',
+          groupIsInternal: true,
+          isAdmin: false,
+          groupOwner: true,
+        };
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupPageShown: true,
+          groupSubpageLength: 2,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('non owner or admin with internal group', done => {
+        const options = {
+          groupId: 'a15262',
+          groupName: 'my-group',
+          groupIsInternal: true,
+          isAdmin: false,
+          groupOwner: false,
+        };
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupPageShown: true,
+          groupSubpageLength: 1,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+
+      test('admin with external group', done => {
+        const options = {
+          groupId: 'a15262',
+          groupName: 'my-group',
+          groupIsInternal: false,
+          isAdmin: true,
+          groupOwner: true,
+        };
+        expected = Object.assign(expected, {
+          projectPageShown: false,
+          groupPageShown: true,
+          groupSubpageLength: 0,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index ead9592..4be674f 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -151,7 +151,7 @@
       if (states.length || !opt_options) { return states; }
 
       // If no missing requirements, either active or ready to submit.
-      if (change.submittable) {
+      if (change.submittable && opt_options.submitEnabled) {
         states.push('Ready to submit');
       } else {
         // Otherwise it is active.
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 06acd94..d3ce73c 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -89,7 +89,7 @@
         mergeable: true,
       };
       let statuses = element.changeStatuses(change);
-      let statusString = element.changeStatusString(change);
+      const statusString = element.changeStatusString(change);
       assert.deepEqual(statuses, []);
       assert.equal(statusString, '');
 
@@ -98,11 +98,15 @@
           {includeDerived: true});
       assert.deepEqual(statuses, ['Active']);
 
-      // With no missing labels
+      // With no missing labels but no submitEnabled option.
       change.submittable = true;
       statuses = element.changeStatuses(change,
           {includeDerived: true});
-      statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Active']);
+
+      // Without missing labels and enabled submit
+      statuses = element.changeStatuses(change,
+          {includeDerived: true, submitEnabled: true});
       assert.deepEqual(statuses, ['Ready to submit']);
 
       change.mergeable = false;
@@ -114,7 +118,7 @@
       delete change.mergeable;
       change.submittable = true;
       statuses = element.changeStatuses(change,
-          {includeDerived: true, mergeable: true});
+          {includeDerived: true, mergeable: true, submitEnabled: true});
       assert.deepEqual(statuses, ['Ready to submit']);
 
       change.submittable = true;
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 c996474..3779402 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
@@ -36,7 +36,7 @@
         margin-bottom: 1em;
       }
       fieldset {
-        border: 1px solid #d1d2d3;
+        border: 1px solid var(--border-color);
       }
       .name {
         align-items: center;
@@ -46,7 +46,7 @@
       #deletedContainer {
         align-items: center;
         background: #f6f6f6;
-        border-bottom: 1px dotted #d1d2d3;
+        border-bottom: 1px dotted var(--border-color);
         display: flex;
         justify-content: space-between;
         min-height: 3em;
@@ -122,7 +122,8 @@
                 labels="[[labels]]"
                 section="[[section.id]]"
                 editing="[[editing]]"
-                groups="[[groups]]">
+                groups="[[groups]]"
+                on-added-permission-removed="_handleAddedPermissionRemoved">
             </gr-permission>
           </template>
           <div id="addPermission">
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index b6d2955..4574e11 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -23,6 +23,11 @@
    * @event access-modified
    */
 
+  /**
+   * Fired when a section that was previously added was removed.
+   * @event added-section-removed
+   */
+
   const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
 
   // The name that gets automatically input when a new reference is added.
@@ -130,6 +135,12 @@
       return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
     },
 
+    _handleAddedPermissionRemoved(e) {
+      const index = e.model.index;
+      this._permissions = this._permissions.slice(0, index).concat(
+          this._permissions.slice(index + 1, this._permissions.length));
+    },
+
     _computeLabelOptions(labels) {
       const labelOptions = [];
       for (const labelName of Object.keys(labels)) {
@@ -184,6 +195,10 @@
     },
 
     _handleRemoveReference() {
+      if (this.section.value.added) {
+        this.dispatchEvent(new CustomEvent('added-section-removed',
+            {bubbles: true}));
+      }
       this._deleted = true;
       this.section.value.deleted = true;
       this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
index ea42101..ad401b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -507,6 +507,23 @@
           assert.isFalse(element._deleted);
           assert.isNotOk(element.section.value.deleted);
         });
+
+        test('removing an added permission', () => {
+          element.editing = true;
+          assert.equal(element._permissions.length, 1);
+          element.$$('gr-permission').fire('added-permission-removed');
+          flushAsynchronousOperations();
+          assert.equal(element._permissions.length, 0);
+        });
+
+        test('remove an added section', () => {
+          const removeStub = sandbox.stub();
+          element.addEventListener('added-section-removed', removeStub);
+          element.editing = true;
+          element.section.value.added = true;
+          MockInteractions.tap(element.$.deleteBtn);
+          assert.isTrue(removeStub.called);
+        });
       });
     });
   });
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 e43f220..3c9cdfb 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
@@ -18,11 +18,14 @@
 <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-admin-nav-behavior/gr-admin-nav-behavior.html">
 <link rel="import" href="../../../behaviors/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">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.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">
 <link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -42,7 +45,38 @@
   <template>
     <style include="shared-styles"></style>
     <style include="gr-menu-page-styles"></style>
-    <style include="gr-page-nav-styles"></style>
+    <style include="gr-page-nav-styles">
+      gr-dropdown-list {
+        --trigger-style: {
+          text-transform: none;
+        }
+      }
+      .breadcrumbText {
+        /* Same as dropdown trigger so chevron spacing is consistent. */
+        padding: 5px 4px;
+      }
+      iron-icon {
+        margin: 0 .2em;
+      }
+      .breadcrumb {
+        align-items: center;
+        display: flex;
+      }
+      .mainHeader {
+        align-items: baseline;
+        border-bottom: 1px solid var(--border-color);
+        display: flex;
+      }
+      .selectText {
+        display: none;
+      }
+      .selectText.show {
+        display: inline-block;
+      }
+      main.breadcrumbs:not(.table) {
+        margin-top: 1em;
+      }
+    </style>
     <gr-page-nav class="navStyles">
       <ul class="sectionContent">
         <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
@@ -74,29 +108,26 @@
         </template>
       </ul>
     </gr-page-nav>
+    <template is="dom-if" if="[[_subsectionLinks.length]]">
+      <section class="mainHeader">
+        <span class="breadcrumb">
+          <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </span>
+        <gr-dropdown-list
+            lowercase
+            id="pageSelect"
+            value="[[_computeSelectValue(params)]]"
+            items="[[_subsectionLinks]]"
+            on-value-change="_handleSubsectionChange">
+        </gr-dropdown-list>
+      </section>
+    </template>
     <template is="dom-if" if="[[_showRepoList]]" restamp="true">
       <main class="table">
         <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
       </main>
     </template>
-    <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-      <main>
-        <gr-repo repo="[[params.repo]]"></gr-repo>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroup]]" restamp="true">
-      <main>
-        <gr-group
-            group-id="[[params.groupId]]"
-            on-name-changed="_updateGroupName"></gr-group>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-      <main>
-        <gr-group-members
-            group-id="[[params.groupId]]"></gr-group-members>
-      </main>
-    </template>
     <template is="dom-if" if="[[_showGroupList]]" restamp="true">
       <main class="table">
         <gr-admin-group-list class="table" params="[[params]]">
@@ -108,35 +139,53 @@
         <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
       </main>
     </template>
+    <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
+      <main class="breadcrumbs">
+        <gr-repo repo="[[params.repo]]"></gr-repo>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroup]]" restamp="true">
+      <main class="breadcrumbs">
+        <gr-group
+            group-id="[[params.groupId]]"
+            on-name-changed="_updateGroupName"></gr-group>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
+      <main class="breadcrumbs">
+        <gr-group-members
+            group-id="[[params.groupId]]"></gr-group-members>
+      </main>
+    </template>
     <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-      <main class="table">
+      <main class="table breadcrumbs">
         <gr-repo-detail-list
             params="[[params]]"
             class="table"></gr-repo-detail-list>
       </main>
     </template>
     <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-      <main class="table">
+      <main class="table breadcrumbs">
         <gr-group-audit-log
             group-id="[[params.groupId]]"
             class="table"></gr-group-audit-log>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-      <main>
+      <main class="breadcrumbs">
         <gr-repo-commands
             repo="[[params.repo]]"></gr-repo-commands>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-      <main class="table">
+      <main class="breadcrumbs">
         <gr-repo-access
             path="[[path]]"
             repo="[[params.repo]]"></gr-repo-access>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-      <main class="table">
+      <main class="table breadcrumbs">
         <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
       </main>
     </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 66f017a..3d430c2 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -17,35 +17,9 @@
 (function() {
   'use strict';
 
-  // Note: noBaseUrl: true is set on entries where the URL is not yet supported
-  // by router abstraction.
-  const ADMIN_LINKS = [{
-    name: 'Repositories',
-    noBaseUrl: true,
-    url: '/admin/repos',
-    view: 'gr-repo-list',
-    viewableToAll: true,
-    children: [],
-  }, {
-    name: 'Groups',
-    section: 'Groups',
-    noBaseUrl: true,
-    url: '/admin/groups',
-    view: 'gr-admin-group-list',
-    children: [],
-  }, {
-    name: 'Plugins',
-    capability: 'viewPlugins',
-    section: 'Plugins',
-    noBaseUrl: true,
-    url: '/admin/plugins',
-    view: 'gr-plugin-list',
-  }];
 
   const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-  const ACCOUNT_CAPABILITIES = ['createProject', 'createGroup', 'viewPlugins'];
-
   Polymer({
     is: 'gr-admin-view',
 
@@ -55,6 +29,7 @@
       path: String,
       adminView: String,
 
+      _breadcrumbParentName: String,
       _repoName: String,
       _groupId: {
         type: Number,
@@ -66,6 +41,7 @@
         type: Boolean,
         value: false,
       },
+      _subsectionLinks: Array,
       _filteredLinks: Array,
       _showDownload: {
         type: Boolean,
@@ -89,6 +65,7 @@
     },
 
     behaviors: [
+      Gerrit.AdminNavBehavior,
       Gerrit.BaseUrlBehavior,
       Gerrit.URLEncodingBehavior,
     ],
@@ -108,113 +85,67 @@
       ];
       return Promise.all(promises).then(result => {
         this._account = result[0];
-        if (!this._account) {
-          // Return so that  account capabilities don't load with no account.
-          return this._filteredLinks = this._filterLinks(link => {
-            return link.viewableToAll;
-          });
+        let options;
+        if (this._repoName) {
+          options = {repoName: this._repoName};
+        } else if (this._groupId) {
+          options = {
+            groupId: this._groupId,
+            groupName: this._groupName,
+            groupIsInternal: this._groupIsInternal,
+            isAdmin: this._isAdmin,
+            groupOwner: this._groupOwner,
+          };
         }
-        this._loadAccountCapabilities();
+
+        return this.getAdminLinks(this._account,
+            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
+            options)
+            .then(res => {
+              this._filteredLinks = res.links;
+              this._breadcrumbParentName = res.expandedSection ?
+                  res.expandedSection.name : '';
+
+              if (!res.expandedSection) {
+                this._subsectionLinks = [];
+                return;
+              }
+              this._subsectionLinks = [res.expandedSection]
+              .concat(res.expandedSection.children).map(section => {
+                return {
+                  text: !section.detailType ? 'Home' : section.name,
+                  value: section.view + (section.detailType || ''),
+                  view: section.view,
+                  url: section.url,
+                  detailType: section.detailType,
+                  parent: this._groupId || this._repoName || '',
+                };
+              });
+            });
       });
     },
 
-    _filterLinks(filterFn) {
-      let links = ADMIN_LINKS.slice(0);
-
-      // Append top-level links that are defined by plugins.
-      links.push(...this.$.jsAPI.getAdminMenuLinks().map(link => ({
-        url: link.url,
-        name: link.text,
-        children: [],
-        noBaseUrl: link.url[0] === '/',
-        view: null,
-        viewableToAll: true,
-      })));
-
-      links = links.filter(filterFn);
-
-      const filteredLinks = [];
-      for (const link of links) {
-        const linkCopy = Object.assign({}, link);
-        linkCopy.children = linkCopy.children ?
-            linkCopy.children.filter(filterFn) : [];
-        if (linkCopy.name === 'Repositories' && this._repoName) {
-          linkCopy.subsection = {
-            name: this._repoName,
-            view: Gerrit.Nav.View.REPO,
-            url: Gerrit.Nav.getUrlForRepo(this._repoName),
-            children: [{
-              name: 'Access',
-              view: Gerrit.Nav.View.REPO,
-              detailType: Gerrit.Nav.RepoDetailView.ACCESS,
-              url: Gerrit.Nav.getUrlForRepoAccess(this._repoName),
-            },
-            {
-              name: 'Commands',
-              view: Gerrit.Nav.View.REPO,
-              detailType: Gerrit.Nav.RepoDetailView.COMMANDS,
-              url: Gerrit.Nav.getUrlForRepoCommands(this._repoName),
-            },
-            {
-              name: 'Branches',
-              view: Gerrit.Nav.View.REPO,
-              detailType: Gerrit.Nav.RepoDetailView.BRANCHES,
-              url: Gerrit.Nav.getUrlForRepoBranches(this._repoName),
-            },
-            {
-              name: 'Tags',
-              view: Gerrit.Nav.View.REPO,
-              detailType: Gerrit.Nav.RepoDetailView.TAGS,
-              url: Gerrit.Nav.getUrlForRepoTags(this._repoName),
-            },
-            {
-              name: 'Dashboards',
-              view: Gerrit.Nav.View.REPO,
-              detailType: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-              url: Gerrit.Nav.getUrlForRepoDashboards(this._repoName),
-            }],
-          };
-        }
-        if (linkCopy.name === 'Groups' && this._groupId && this._groupName) {
-          linkCopy.subsection = {
-            name: this._groupName,
-            view: Gerrit.Nav.View.GROUP,
-            url: Gerrit.Nav.getUrlForGroup(this._groupId),
-            children: [],
-          };
-          if (this._groupIsInternal) {
-            linkCopy.subsection.children.push({
-              name: 'Members',
-              detailType: Gerrit.Nav.GroupDetailView.MEMBERS,
-              view: Gerrit.Nav.View.GROUP,
-              url: Gerrit.Nav.getUrlForGroupMembers(this._groupId),
-            });
-          }
-          if (this._groupIsInternal && (this._isAdmin || this._groupOwner)) {
-            linkCopy.subsection.children.push(
-                {
-                  name: 'Audit Log',
-                  detailType: Gerrit.Nav.GroupDetailView.LOG,
-                  view: Gerrit.Nav.View.GROUP,
-                  url: Gerrit.Nav.getUrlForGroupLog(this._groupId),
-                }
-            );
-          }
-        }
-
-        filteredLinks.push(linkCopy);
-      }
-      return filteredLinks;
+    _computeSelectValue(params) {
+      if (!params || !params.view) { return; }
+      return params.view + (params.detail || '');
     },
 
-    _loadAccountCapabilities() {
-      return this.$.restAPI.getAccountCapabilities(ACCOUNT_CAPABILITIES)
-          .then(capabilities => {
-            this._filteredLinks = this._filterLinks(link => {
-              return !link.capability ||
-                  capabilities.hasOwnProperty(link.capability);
-            });
-          });
+    _selectedIsCurrentPage(selected) {
+      return (selected.parent === (this._repoName || this._groupId) &&
+          selected.view === this.params.view &&
+          selected.detailType === this.params.detail);
+    },
+
+    _handleSubsectionChange(e) {
+      const selected = this._subsectionLinks
+          .find(section => section.value === e.detail.value);
+
+      // This is when it gets set initially.
+      if (this._selectedIsCurrentPage(selected)) {
+        return;
+      }
+      Gerrit.Nav.navigateToRelativeUrl(selected.url);
     },
 
     _paramsChanged(params) {
@@ -248,16 +179,26 @@
       this.set('_showPluginList', isAdminView &&
           params.adminView === 'gr-plugin-list');
 
+      let needsReload = false;
       if (params.repo !== this._repoName) {
         this._repoName = params.repo || '';
         // Reloads the admin menu.
-        this.reload();
+        needsReload = true;
       }
       if (params.groupId !== this._groupId) {
         this._groupId = params.groupId || '';
         // Reloads the admin menu.
-        this.reload();
+        needsReload = true;
       }
+      if (this._breadcrumbParentName && !params.groupId && !params.repo) {
+        needsReload = true;
+      }
+      if (!needsReload) { return; }
+      this.reload();
+    },
+
+    _computeSelectedTitle(params) {
+      return this.getSelectedTitle(params.view);
     },
 
     // TODO (beckysiegel): Update these functions after router abstraction is
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index ad1fe46..56079e3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -37,7 +37,7 @@
     let element;
     let sandbox;
 
-    setup(() => {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
       stub('gr-rest-api-interface', {
@@ -45,7 +45,9 @@
           return Promise.resolve({});
         },
       });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+      const pluginsLoaded = Promise.resolve();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(pluginsLoaded);
+      pluginsLoaded.then(() => flush(done));
     });
 
     teardown(() => {
@@ -79,7 +81,6 @@
         name: 'Repositories',
         url: '/admin/repos',
         view: 'gr-repo-list',
-        children: [],
       }];
 
       element.params = {
@@ -95,6 +96,9 @@
     });
 
     test('_filteredLinks admin', done => {
+      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+        name: 'test-user',
+      }));
       sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({
           createGroup: true,
@@ -102,35 +106,36 @@
           viewPlugins: true,
         });
       });
-      element._loadAccountCapabilities().then(() => {
+      element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 3);
 
         // Repos
-        assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
 
         // Groups
-        assert.equal(element._filteredLinks[1].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
 
         // Plugins
-        assert.equal(element._filteredLinks[2].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
         done();
       });
     });
 
     test('_filteredLinks non admin authenticated', done => {
+      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+        name: 'test-user',
+      }));
       sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({});
       });
-      element._loadAccountCapabilities().then(() => {
+      element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 2);
 
         // Repos
-        assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
 
         // Groups
-        assert.equal(element._filteredLinks[1].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
         done();
       });
     });
@@ -140,7 +145,6 @@
         assert.equal(element._filteredLinks.length, 1);
 
         // Repos
-        assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
         done();
       });
@@ -156,24 +160,27 @@
         assert.deepEqual(element._filteredLinks[1], {
           url: '/internal/link/url',
           name: 'internal link text',
-          children: [],
           noBaseUrl: true,
           view: null,
           viewableToAll: true,
+          target: null,
         });
         assert.deepEqual(element._filteredLinks[2], {
           url: 'http://external/link/url',
           name: 'external link text',
-          children: [],
           noBaseUrl: false,
           view: null,
           viewableToAll: true,
+          target: '_blank',
         });
       });
     });
 
     test('Repo shows up in nav', done => {
       element._repoName = 'Test Repo';
+      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+        name: 'test-user',
+      }));
       sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({
           createGroup: true,
@@ -181,18 +188,45 @@
           viewPlugins: true,
         });
       });
-      element._loadAccountCapabilities().then(() => {
+      element.reload().then(() => {
+        flushAsynchronousOperations();
+        assert.equal(Polymer.dom(element.root)
+            .querySelectorAll('.sectionTitle').length, 3);
+        assert.equal(element.$$('.breadcrumbText').innerText, 'Test Repo');
+        assert.equal(element.$$('#pageSelect').items.length, 6);
+        done();
+      });
+    });
+
+    test('Group shows up in nav', done => {
+      element._groupId = 'a15262';
+      element._groupName = 'my-group';
+      element._groupIsInternal = true;
+      element._isAdmin = true;
+      element._groupOwner = false;
+      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+        name: 'test-user',
+      }));
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      element.reload().then(() => {
+        flushAsynchronousOperations();
         assert.equal(element._filteredLinks.length, 3);
 
         // Repos
-        assert.equal(element._filteredLinks[0].children.length, 0);
-        assert.equal(element._filteredLinks[0].subsection.name, 'Test Repo');
+        assert.isNotOk(element._filteredLinks[0].subsection);
 
         // Groups
-        assert.equal(element._filteredLinks[1].children.length, 0);
+        assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+        assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
 
         // Plugins
-        assert.equal(element._filteredLinks[2].children.length, 0);
+        assert.isNotOk(element._filteredLinks[2].subsection);
         done();
       });
     });
@@ -247,6 +281,188 @@
       element.$$('gr-group').fire('name-changed', {name: newName});
     });
 
+    test('dropdown displays if there is a subsection', () => {
+      assert.isNotOk(element.$$('.mainHeader'));
+      element._subsectionLinks = [
+        {
+          text: 'Home',
+          value: 'repo',
+          view: 'repo',
+          url: '',
+          parent: 'my-repo',
+          detailType: undefined,
+        },
+      ];
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('.mainHeader'));
+      element._subsectionLinks = undefined;
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.$$('.mainHeader')).display, 'none');
+    });
+
+    test('Dropdown only triggers navigation on explicit select', done => {
+      element._repoName = 'my-repo';
+      element.params = {
+        repo: 'my-repo',
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.ACCESS,
+      };
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      sandbox.stub(element.$.restAPI, 'getAccount', () => {
+        return Promise.resolve({_id: 1});
+      });
+      flushAsynchronousOperations();
+      const expectedFilteredLinks = [
+        {
+          name: 'Repositories',
+          noBaseUrl: true,
+          url: '/admin/repos',
+          view: 'gr-repo-list',
+          viewableToAll: true,
+          subsection: {
+            name: 'my-repo',
+            view: 'repo',
+            url: '',
+            children: [
+              {
+                name: 'Access',
+                view: 'repo',
+                detailType: 'access',
+                url: '',
+              },
+              {
+                name: 'Commands',
+                view: 'repo',
+                detailType: 'commands',
+                url: '',
+              },
+              {
+                name: 'Branches',
+                view: 'repo',
+                detailType: 'branches',
+                url: '',
+              },
+              {
+                name: 'Tags',
+                view: 'repo',
+                detailType: 'tags',
+                url: '',
+              },
+              {
+                name: 'Dashboards',
+                view: 'repo',
+                detailType: 'dashboards',
+                url: '',
+              },
+            ],
+          },
+        },
+        {
+          name: 'Groups',
+          section: 'Groups',
+          noBaseUrl: true,
+          url: '/admin/groups',
+          view: 'gr-admin-group-list',
+        },
+        {
+          name: 'Plugins',
+          capability: 'viewPlugins',
+          section: 'Plugins',
+          noBaseUrl: true,
+          url: '/admin/plugins',
+          view: 'gr-plugin-list',
+        },
+      ];
+      const expectedSubsectionLinks = [
+        {
+          text: 'Home',
+          value: 'repo',
+          view: 'repo',
+          url: '',
+          parent: 'my-repo',
+          detailType: undefined,
+        },
+        {
+          text: 'Access',
+          value: 'repoaccess',
+          view: 'repo',
+          url: '',
+          detailType: 'access',
+          parent: 'my-repo',
+        },
+        {
+          text: 'Commands',
+          value: 'repocommands',
+          view: 'repo',
+          url: '',
+          detailType: 'commands',
+          parent: 'my-repo',
+        },
+        {
+          text: 'Branches',
+          value: 'repobranches',
+          view: 'repo',
+          url: '',
+          detailType: 'branches',
+          parent: 'my-repo',
+        },
+        {
+          text: 'Tags',
+          value: 'repotags',
+          view: 'repo',
+          url: '',
+          detailType: 'tags',
+          parent: 'my-repo',
+        },
+        {
+          text: 'Dashboards',
+          value: 'repodashboards',
+          view: 'repo',
+          url: '',
+          detailType: 'dashboards',
+          parent: 'my-repo',
+        },
+      ];
+      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      sandbox.spy(element, '_selectedIsCurrentPage');
+      sandbox.spy(element, '_handleSubsectionChange');
+      element.reload().then(() => {
+        assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+        assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+        assert.equal(element.$$('#pageSelect').value, 'repoaccess');
+        assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+        // Doesn't trigger navigation from the page select menu.
+        assert.isFalse(Gerrit.Nav.navigateToRelativeUrl.called);
+
+        // When explicitly changed, navigation is called
+        element.$$('#pageSelect').value = 'repo';
+        assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+        assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.calledOnce);
+        done();
+      });
+    });
+
+    test('_selectedIsCurrentPage', () => {
+      element._repoName = 'my-repo';
+      element.params = {view: 'repo', repo: 'my-repo'};
+      const selected = {
+        view: 'repo',
+        detailType: undefined,
+        parent: 'my-repo',
+      };
+      assert.isTrue(element._selectedIsCurrentPage(selected));
+      selected.parent = 'my-second-repo';
+      assert.isFalse(element._selectedIsCurrentPage(selected));
+      selected.detailType = 'detailType';
+      assert.isFalse(element._selectedIsCurrentPage(selected));
+    });
+
     suite('_computeSelectedClass', () => {
       setup(() => {
         sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
@@ -343,6 +559,7 @@
               }));
           sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
               .returns(Promise.resolve(true));
+          return element.reload();
         });
 
         test('group list', () => {
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 d1fc822..70dfe52 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
@@ -40,7 +40,7 @@
       gr-autocomplete {
         border: none;
         --gr-autocomplete: {
-          border: 1px solid #d1d2d3;
+          border: 1px solid var(--border-color);
           border-radius: 2px;
           font-size: var(--font-size-normal);
           height: 2em;
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 f2b8855..c1ac650 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
@@ -21,6 +21,8 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.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/gr-subpage-styles.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
@@ -32,19 +34,9 @@
 <dom-module id="gr-group-members">
   <template>
     <style include="gr-form-styles"></style>
+    <style include="gr-table-styles"></style>
+    <style include="gr-subpage-styles"></style>
     <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
       .input {
         width: 15em;
       }
@@ -57,14 +49,14 @@
         }
       }
       a {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         text-decoration: none;
       }
       a:hover {
         text-decoration: underline;
       }
       th {
-        border-bottom: 1px solid #eee;
+        border-bottom: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
         text-align: left;
       }
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 e9b7aa3..d21247a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.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/gr-subpage-styles.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-copy-clipboard/gr-copy-clipboard.html">
@@ -28,23 +29,12 @@
 
 <dom-module id="gr-group">
   <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles">
       h3.edited:after {
         color: #444;
         content: ' *';
       }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
       .inputUpdateBtn {
         margin-top: .3em;
       }
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index cc0ca51..22f461b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -40,12 +40,12 @@
         margin: .3em .7em;
       }
       .rules {
-        background: #fafafa;
-        border: 1px solid #d1d2d3;
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
         border-bottom: 0;
       }
       .editing .rules {
-        border-bottom: 1px solid #d1d2d3;
+        border-bottom: 1px solid var(--border-color);
       }
       .title {
         margin-bottom: .3em;
@@ -72,7 +72,7 @@
       }
       .deleted #deletedContainer {
         align-items: baseline;
-        border: 1px solid #d1d2d3;
+        border: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
         padding: .7em;
@@ -115,7 +115,8 @@
                 group-name="[[_computeGroupName(groups, rule.id)]]"
                 permission="[[permission.id]]"
                 rule="{{rule}}"
-                section="[[section]]"></gr-rule-editor>
+                section="[[section]]"
+                on-added-rule-removed="_handleAddedRuleRemoved"></gr-rule-editor>
           </template>
           <div id="addRule">
             <gr-autocomplete
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 01caf1d..31d371d 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -25,6 +25,11 @@
    * @event access-modified
    */
 
+  /**
+   * Fired when a permission that was previously added was removed.
+   * @event added-permission-removed
+   */
+
   Polymer({
     is: 'gr-permission',
 
@@ -117,6 +122,12 @@
       }
     },
 
+    _handleAddedRuleRemoved(e) {
+      const index = e.model.index;
+      this._rules = this._rules.slice(0, index)
+          .concat(this._rules.slice(index + 1, this._rules.length));
+    },
+
     _handleValueChange() {
       this.permission.value.modified = true;
       // Allows overall access page to know a change has been made.
@@ -124,6 +135,10 @@
     },
 
     _handleRemovePermission() {
+      if (this.permission.value.added) {
+        this.dispatchEvent(new CustomEvent('added-permission-removed',
+            {bubbles: true}));
+      }
       this._deleted = true;
       this.permission.value.deleted = true;
       this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index 6799d10..e29c4a2 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -327,11 +327,36 @@
         assert.equal(Object.keys(element.permission.value.rules).length, 2);
       });
 
+      test('removing an added rule', () => {
+        element.name = 'Priority';
+        element.section = 'refs/*';
+        element.groups = {};
+        element.$.groupAutocomplete.text = 'new group name';
+        assert.equal(element._rules.length, 2);
+        element.$$('gr-rule-editor').fire('added-rule-removed');
+        flushAsynchronousOperations();
+        assert.equal(element._rules.length, 1);
+      });
+
+      test('removing an added permission', () => {
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-permission-removed', removeStub);
+        element.editing = true;
+        element.name = 'Priority';
+        element.section = 'refs/*';
+        element.permission.value.added = true;
+        MockInteractions.tap(element.$.removeBtn);
+        assert.isTrue(removeStub.called);
+      });
+
       test('removing the permission', () => {
         element.editing = true;
         element.name = 'Priority';
         element.section = 'refs/*';
 
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-permission-removed', removeStub);
+
         assert.isFalse(element.$.permission.classList.contains('deleted'));
         assert.isFalse(element._deleted);
         MockInteractions.tap(element.$.removeBtn);
@@ -340,6 +365,7 @@
         MockInteractions.tap(element.$.undoRemoveBtn);
         assert.isFalse(element.$.permission.classList.contains('deleted'));
         assert.isFalse(element._deleted);
+        assert.isFalse(removeStub.called);
       });
 
       test('modify a permission', () => {
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 47f47ec..0ceac7c 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
@@ -22,6 +22,7 @@
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/gr-subpage-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-rest-api-interface/gr-rest-api-interface.html">
@@ -31,14 +32,13 @@
 
 <dom-module id="gr-repo-access">
   <template>
-    <style include="shared-styles">
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles">
       gr-button,
       #inheritsFrom,
       #editInheritFromInput,
       .editing #inheritFromName,
-      .weblinks,
-      #loadingText,
-      .loading {
+      .weblinks{
         display: none;
       }
       #inheritsFrom.show {
@@ -50,7 +50,6 @@
         margin-right: .2em;
       }
       .weblinks.show,
-      #loadingText.loading,
       .referenceContainer {
         display: block;
       }
@@ -69,7 +68,7 @@
     </style>
     <style include="gr-menu-page-styles"></style>
     <main class$="[[_computeMainClass(_isAdmin, _canUpload, _editing)]]">
-      <div id="loadingText" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
         Loading...
       </div>
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
@@ -104,13 +103,16 @@
         <template
             is="dom-repeat"
             items="{{_sections}}"
+            initial-count="5"
+            target-framerate="60"
             as="section">
           <gr-access-section
               capabilities="[[_capabilities]]"
               section="{{section}}"
               labels="[[_labels]]"
               editing="[[_editing]]"
-              groups="[[_groups]]"></gr-access-section>
+              groups="[[_groups]]"
+              on-added-section-removed="_handleAddedSectionRemoved"></gr-access-section>
         </template>
         <div class="referenceContainer">
           <gr-button id="addReferenceBtn"
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 853e316..f307090 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
@@ -243,6 +243,12 @@
       return inheritsFrom ? 'show' : '';
     },
 
+    _handleAddedSectionRemoved(e) {
+      const index = e.model.index;
+      this._sections = this._sections.slice(0, index)
+          .concat(this._sections.slice(index + 1, this._sections.length));
+    },
+
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
       if (!editingOld || editing) { return; }
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 66fece3..2f0b1b8 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
@@ -301,6 +301,14 @@
         flushAsynchronousOperations();
       });
 
+      test('removing an added section', () => {
+        element.editing = true;
+        assert.equal(element._sections.length, 1);
+        element.$$('gr-access-section').fire('added-section-removed');
+        flushAsynchronousOperations();
+        assert.equal(element._sections.length, 0);
+      });
+
       test('button visibility for non admin', () => {
         assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
         assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
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 b686237..510f654 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
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.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/gr-subpage-styles.html">
 <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">
@@ -30,20 +31,8 @@
 
 <dom-module id="gr-repo-commands">
   <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-    </style>
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles"></style>
     <style include="gr-form-styles"></style>
     <main class="gr-form-styles read-only">
       <h1 id="Title">Repository Commands</h1>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index fdbc239..f3892ea 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -16,6 +16,7 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.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-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-repo-dashboards">
@@ -52,7 +53,7 @@
           </tr>
           <template is="dom-repeat" items="[[item.dashboards]]">
             <tr class="table">
-              <td class="name"><a href$="[[item.url]]">[[item.path]]</a></td>
+              <td class="name"><a href$="[[_getUrl(item.url)]]">[[item.path]]</a></td>
               <td class="title">[[item.title]]</td>
               <td class="desc">[[item.description]]</td>
               <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index a4c2c03..a852533 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -68,6 +68,12 @@
       });
     },
 
+    _getUrl(url) {
+      if (!url) { return ''; }
+
+      return Gerrit.Nav.navigateToRelativeUrl(url);
+    },
+
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index bd4019e..ab57ba5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -243,6 +243,17 @@
       });
     });
 
+    suite('test url', () => {
+      test('_getUrl', () => {
+        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl',
+            () => '/r/dashboard/test');
+
+        assert.equal(element._getUrl('/dashboard/test'), '/r/dashboard/test');
+
+        assert.equal(element._getUrl(undefined), '');
+      });
+    });
+
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
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 2c0a737..5560972 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
@@ -63,7 +63,10 @@
         type: Boolean,
         value: true,
       },
-      _filter: String,
+      _filter: {
+        type: String,
+        value: '',
+      },
     },
 
     behaviors: [
@@ -115,7 +118,8 @@
       this._repos = [];
       return this.$.restAPI.getRepos(filter, reposPerPage, offset)
           .then(repos => {
-            if (!repos) { return; }
+            // Late response.
+            if (filter !== this._filter || !repos) { return; }
             this._repos = Object.keys(repos)
              .map(key => {
                const repo = repos[key];
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
index 731437fa..4bc023f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -56,6 +56,7 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      sandbox.stub(page, 'show');
       element = fixture('basic');
       counter = 0;
     });
@@ -118,6 +119,11 @@
     });
 
     suite('filter', () => {
+      setup(() => {
+        repos = _.times(25, repoGenerator);
+        reposFiltered = _.times(1, repoGenerator);
+      });
+
       test('_paramsChanged', done => {
         sandbox.stub(element.$.restAPI, 'getRepos', () => {
           return Promise.resolve(repos);
@@ -132,6 +138,19 @@
           done();
         });
       });
+
+      test('latest repos requested are always set', done => {
+        const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
+        repoStub.withArgs('test').returns(Promise.resolve(repos));
+        repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
+        element._filter = 'test';
+
+        // Repos are not set because the element._filter differs.
+        element._getRepos('filter', 25, 0).then(() => {
+          assert.deepEqual(element._repos, []);
+          done();
+        });
+      });
     });
 
     suite('loading', () => {
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 3eb9d62..85dcdbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -24,14 +24,14 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
+
 <dom-module id="gr-repo">
   <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
+    <style="shared-styles"></style>
+    <style include="gr-subpage-styles">
       h2.edited:after {
         color: #444;
         content: ' *';
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 2d05a3b..febd446 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
@@ -29,7 +29,7 @@
   <template>
     <style include="shared-styles">
       :host {
-        border-bottom: 1px solid #d1d2d3;
+        border-bottom: 1px solid var(--border-color);
         padding: .7em;
         display: block;
       }
@@ -68,7 +68,7 @@
         display: block;
       }
       .groupPath {
-        color: #666;
+        color: var(--deemphasized-text-color);
       }
     </style>
     <style include="gr-form-styles"></style>
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 0a719be..4af4952 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
@@ -23,6 +23,11 @@
    * @event access-modified
    */
 
+  /**
+   * Fired when a rule that was previously added was removed.
+   * @event added-rule-removed
+   */
+
   const PRIORITY_OPTIONS = [
     'BATCH',
     'INTERACTIVE',
@@ -36,11 +41,11 @@
 
   const FORCE_PUSH_OPTIONS = [
     {
-      name: 'No Force Push',
+      name: 'Block all pushes, block force push only',
       value: false,
     },
     {
-      name: 'Force Push',
+      name: 'Allow fast-forward only push, allow all pushes',
       value: true,
     },
   ];
@@ -188,6 +193,10 @@
     },
 
     _handleRemoveRule() {
+      if (this.rule.value.added) {
+        this.dispatchEvent(new CustomEvent('added-rule-removed',
+            {bubbles: true}));
+      }
       this._deleted = true;
       this.rule.value.deleted = true;
       this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
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 864bf16..5b6f947 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
@@ -52,11 +52,11 @@
           () => {
             const FORCE_PUSH_OPTIONS = [
               {
-                name: 'No Force Push',
+                name: 'Block all pushes, block force push only',
                 value: false,
               },
               {
-                name: 'Force Push',
+                name: 'Allow fast-forward only push, allow all pushes',
                 value: true,
               },
             ];
@@ -304,6 +304,7 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        element.rule.value.added = true;
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -313,9 +314,9 @@
         const expectedRuleValue = {
           action: 'ALLOW',
           force: false,
+          added: true,
         };
         assert.deepEqual(element.rule.value, expectedRuleValue);
-        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
         test('values are set correctly', () => {
           assert.equal(element.$.action.bindValue, expectedRuleValue.action);
           assert.equal(element.$.force.bindValue, expectedRuleValue.action);
@@ -331,6 +332,15 @@
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
       });
+
+      test('remove value', () => {
+        element.editing = true;
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-rule-removed', removeStub);
+        MockInteractions.tap(element.$.removeBtn);
+        flushAsynchronousOperations();
+        assert.isTrue(removeStub.called);
+      });
     });
 
     suite('already existing rule with labels', () => {
@@ -374,10 +384,13 @@
       });
 
       test('modify value', () => {
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-rule-removed', removeStub);
         assert.isNotOk(element.rule.value.modified);
         Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
         flushAsynchronousOperations();
         assert.isTrue(element.rule.value.modified);
+        assert.isFalse(removeStub.called);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
@@ -402,6 +415,7 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        element.rule.value.added = true;
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -414,9 +428,9 @@
           max: element.label.values[element.label.values.length - 1].value,
           min: element.label.values[0].value,
           action: 'ALLOW',
+          added: true,
         };
         assert.deepEqual(element.rule.value, expectedRuleValue);
-        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
         test('values are set correctly', () => {
           assert.equal(
               element.$.action.bindValue,
@@ -492,6 +506,7 @@
         element.section = 'refs/*';
         element._setupValues(element.rule);
         flushAsynchronousOperations();
+        element.rule.value.added = true;
       });
 
       test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -501,9 +516,9 @@
         const expectedRuleValue = {
           action: 'ALLOW',
           force: false,
+          added: true,
         };
         assert.deepEqual(element.rule.value, expectedRuleValue);
-        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
         test('values are set correctly', () => {
           assert.equal(element.$.action.bindValue, expectedRuleValue.action);
           assert.equal(element.$.force.bindValue, expectedRuleValue.action);
@@ -561,44 +576,5 @@
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
       });
     });
-
-    suite('new edit rule', () => {
-      setup(() => {
-        element.group = 'Group Name';
-        element.permission = 'editTopicName';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        const expectedRuleValue = {
-          action: 'ALLOW',
-          force: false,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
-        test('values are set correctly', () => {
-          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-        });
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.force.bindValue = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-    });
   });
 </script>
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 6005b92..1ee0b6e 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
@@ -36,16 +36,11 @@
       :host {
         display: table-row;
       }
-      :host:focus {
+      :host(:focus) {
         outline: none;
       }
       :host(:hover) {
-        background-color: #eeeeee;
-      }
-      :host([selected]) {
-        /* Double the value used in the file list due to the parent table having
-          `border-collapse: collapse;` */
-        border-left: .7rem solid var(--color-link);
+        background-color: var(--hover-background-color);
       }
       :host([needs-review]) {
         font-family: var(--font-family-bold);
@@ -91,7 +86,7 @@
         padding: .4rem .6rem;
       }
       a {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         cursor: pointer;
         display: inline-block;
         text-decoration: none;
@@ -99,12 +94,6 @@
       a:hover {
         text-decoration: underline;
       }
-      .positionIndicator {
-        visibility: hidden;
-      }
-      :host([selected]) .positionIndicator {
-        visibility: visible;
-      }
       .u-monospace {
         font-family: var(--monospace-font-family);
       }
@@ -123,19 +112,16 @@
       }
       .comma,
       .placeholder {
-        color: rgba(0, 0, 0, .54);
+        color: var(--deemphasized-text-color);
       }
       @media only screen and (max-width: 50em) {
         :host {
           display: flex;
         }
-        :host([selected]) {
-          border-left: none;
-        }
       }
     </style>
     <style include="gr-change-list-styles"></style>
-    <td class="cell keyboard"></td>
+    <td class="cell leftPadding"></td>
     <td class="cell star" hidden$="[[!showStar]]" hidden>
       <gr-change-star change="{{change}}"></gr-change-star>
     </td>
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 a9c3c6f..0ce754d 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
@@ -22,6 +22,7 @@
 <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="../gr-change-list/gr-change-list.html">
+<link rel="import" href="../gr-repo-header/gr-repo-header.html">
 <link rel="import" href="../gr-user-header/gr-user-header.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -33,14 +34,15 @@
         display: block;
       }
       .loading {
-        color: #666;
+        color: var(--deemphasized-text-color);
         padding: 1em var(--default-horizontal-margin);
       }
       gr-change-list {
         width: 100%;
       }
-      gr-user-header {
-        border-bottom: 1px solid #ddd;
+      gr-user-header,
+      gr-repo-header {
+        border-bottom: 1px solid var(--border-color);
       }
       nav {
         align-items: center;
@@ -71,11 +73,14 @@
     </style>
     <div class="loading" hidden$="[[!_loading]]" hidden>Loading...</div>
     <div hidden$="[[_loading]]" hidden>
+      <gr-repo-header
+          repo="[[_repo]]"
+          class$="[[_computeHeaderClass(_repo)]]"></gr-repo-header>
       <gr-user-header
           user-id="[[_userId]]"
           show-dashboard-link
           logged-in="[[_loggedIn]]"
-          class$="[[_computeUserHeaderClass(_userId)]]"></gr-user-header>
+          class$="[[_computeHeaderClass(_userId)]]"></gr-user-header>
       <gr-change-list
           account="[[account]]"
           changes="{{_changes}}"
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 1728bc1..2a05a2f 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
@@ -24,6 +24,9 @@
 
   const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
+  const REPO_QUERY_PATTERN =
+      /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
   const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
   Polymer({
@@ -114,6 +117,12 @@
         type: String,
         value: null,
       },
+
+      /** @type {?String} */
+      _repo: {
+        type: String,
+        value: null,
+      },
     },
 
     listeners: {
@@ -138,7 +147,9 @@
         this.set('viewState.offset', this._offset);
       }
 
-      this.fire('title-change', {title: this._query});
+      // NOTE: This method may be called before attachment. Fire title-change
+      // in an async so that attachment to the DOM can take place first.
+      this.async(() => this.fire('title-change', {title: this._query}));
 
       this._getPreferences().then(prefs => {
         this._changesPerPage = prefs.changes_per_page;
@@ -227,16 +238,22 @@
     },
 
     _changesChanged(changes) {
-      if (!changes || !changes.length ||
-          !USER_QUERY_PATTERN.test(this._query)) {
-        this._userId = null;
+      this._userId = null;
+      this._repo = null;
+      if (!changes || !changes.length) {
         return;
       }
-      this._userId = changes[0].owner.email;
+      if (USER_QUERY_PATTERN.test(this._query) && changes[0].owner.email) {
+        this._userId = changes[0].owner.email;
+        return;
+      }
+      if (REPO_QUERY_PATTERN.test(this._query)) {
+        this._repo = changes[0].project;
+      }
     },
 
-    _computeUserHeaderClass(userId) {
-      return userId ? '' : 'hide';
+    _computeHeaderClass(id) {
+      return id ? '' : 'hide';
     },
 
     _computePage(offset, changesPerPage) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 2091c7d..3911364 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -156,6 +156,42 @@
       });
     });
 
+    test('_userId query without email', done => {
+      assert.isNull(element._userId);
+      element._query = 'owner: foo@bar';
+      element._changes = [{owner: {}}];
+      flush(() => {
+        assert.isNull(element._userId);
+        done();
+      });
+    });
+
+    test('_repo query', done => {
+      assert.isNull(element._repo);
+      element._query = 'project: test-repo';
+      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+      flush(() => {
+        assert.equal(element._repo, 'test-repo');
+        element._query = 'foo bar baz';
+        element._changes = [{owner: {email: 'foo@bar'}}];
+        assert.isNull(element._repo);
+        done();
+      });
+    });
+
+    test('_repo query with open status', done => {
+      assert.isNull(element._repo);
+      element._query = 'project:test-repo status:open';
+      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+      flush(() => {
+        assert.equal(element._repo, 'test-repo');
+        element._query = 'foo bar baz';
+        element._changes = [{owner: {email: 'foo@bar'}}];
+        assert.isNull(element._repo);
+        done();
+      });
+    });
+
     suite('query based navigation', () => {
       setup(() => {
         sandbox.stub(Gerrit.Nav, 'getUrlForChange', () => '/r/c/1');
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 d917423..3509f35 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
@@ -38,7 +38,7 @@
     </style>
     <table id="changeList">
       <tr class="topHeader">
-        <th class="keyboard"></th>
+        <th class="leftPadding"></th>
         <th class="star" hidden$="[[!showStar]]" hidden></th>
         <th class="number" hidden$="[[!showNumber]]" hidden>#</th>
         <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
@@ -57,7 +57,7 @@
           index-as="sectionIndex">
         <template is="dom-if" if="[[changeSection.sectionName]]">
           <tr class="groupHeader">
-            <td class="keyboard"></td>
+            <td class="leftPadding"></td>
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
@@ -69,7 +69,7 @@
         </template>
         <template is="dom-if" if="[[!changeSection.results.length]]">
           <tr class="noChanges">
-            <td class="keyboard"></td>
+            <td class="leftPadding"></td>
             <td class="star" hidden$="[[!showStar]]" hidden></td>
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
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 d97e7f4..1935962 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
@@ -15,10 +15,11 @@
 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="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../change-list/gr-change-list/gr-change-list.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-user-header/gr-user-header.html">
 
@@ -30,7 +31,7 @@
         display: block;
       }
       .loading {
-        color: #666;
+        color: var(--deemphasized-text-color);
         padding: 1em var(--default-horizontal-margin);
       }
       gr-change-list {
@@ -40,7 +41,7 @@
         display: none;
       }
       gr-user-header {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
       }
       @media only screen and (max-width: 50em) {
         .loading {
@@ -62,6 +63,7 @@
           sections="[[_results]]"></gr-change-list>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-dashboard-view.js"></script>
 </dom-module>
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 b6e06d8..f86c98c 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
@@ -230,6 +230,8 @@
             };
           });
         });
+      }).then(() => {
+        this.$.reporting.dashboardDisplayed();
       }).catch(err => {
         this._loading = false;
         console.warn(err);
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 a3aa4f2..a1da018 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
@@ -55,7 +55,7 @@
       });
       const paramsChanged = element._paramsChanged.bind(element);
       sandbox.stub(element, '_paramsChanged', params => {
-        paramsChanged(params).then(resolver());
+        paramsChanged(params).then(() => resolver());
       });
     });
 
@@ -262,5 +262,17 @@
         dashboard: 'dashboard',
       };
     });
+
+    test('params change triggers dashboardDisplayed()', () => {
+      sandbox.stub(element.$.reporting, 'dashboardDisplayed');
+      element.params = {
+        view: Gerrit.Nav.View.DASHBOARD,
+        project: 'project',
+        dashboard: 'dashboard',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
new file mode 100644
index 0000000..2328725
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
@@ -0,0 +1,41 @@
+<!--
+@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/dashboard-header-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-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-repo-header">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="dashboard-header-styles"></style>
+    <div class="info">
+      <h1 class$="name">
+        [[repo]]
+        <hr/>
+      </h1>
+      <div>
+        <span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-header.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
new file mode 100644
index 0000000..cd6eb77
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -0,0 +1,40 @@
+/**
+ * @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-repo-header',
+    properties: {
+      /** @type {?String} */
+      repo: {
+        type: String,
+        observer: '_repoChanged',
+      },
+      /** @type {String|null} */
+      _repoUrl: String,
+    },
+
+    _repoChanged(repoName) {
+      if (!repoName) {
+        this._repoUrl = null;
+        return;
+      }
+      this._repoUrl = Gerrit.Nav.getUrlForRepo(repoName);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
new file mode 100644
index 0000000..a561e09
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
@@ -0,0 +1,73 @@
+<!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-header</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-header.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-header></gr-repo-header>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-header tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('loads and clears account info', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountDetails')
+          .returns(Promise.resolve({
+            name: 'foo',
+            email: 'bar',
+            registered_on: '2015-03-12 18:32:08.000000000',
+          }));
+      sandbox.stub(element.$.restAPI, 'getAccountStatus')
+          .returns(Promise.resolve('baz'));
+
+      element.userId = 'foo.bar@baz';
+      flush(() => {
+        assert.isOk(element._accountDetails);
+        assert.isOk(element._status);
+
+        element.userId = null;
+        flush(() => {
+          flushAsynchronousOperations();
+          assert.isNull(element._accountDetails);
+          assert.isNull(element._status);
+
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index 1c19ab2..89e2b7d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -20,35 +20,13 @@
 <link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/dashboard-header-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-user-header">
   <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        height: 9em;
-        width: 100%;
-      }
-      gr-avatar {
-        display: inline-block;
-        height: 7em;
-        left: 1em;
-        margin: 1em;
-        top: 1em;
-        width: 7em;
-      }
-      .info {
-        display: inline-block;
-        padding: 1em;
-        vertical-align: top;
-      }
-      .info > div > span {
-        display: inline-block;
-        font-weight: bold;
-        text-align: right;
-        width: 4em;
-      }
+    <style include="shared-styles"></style>
+    <style include="dashboard-header-styles">
       .name {
         display: inline-block;
       }
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 d54bbc3..62e9033 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
@@ -55,7 +55,7 @@
       }
       #actionLoadingMessage {
         align-items: center;
-        color: #777;
+        color: var(--deemphasized-text-color);
       }
       #confirmSubmitDialog .changeSubject {
         margin: 1em;
@@ -95,7 +95,9 @@
               is="dom-repeat"
               items="[[_topLevelPrimaryActions]]"
               as="action">
-            <gr-button title$="[[action.title]]"
+            <gr-button
+                title$="[[action.title]]"
+                has-tooltip="[[_computeHasTooltip(action.title)]]"
                 primary$="[[action.__primary]]"
                 data-action-key$="[[action.__key]]"
                 data-action-type$="[[action.__type]]"
@@ -110,7 +112,9 @@
               is="dom-repeat"
               items="[[_topLevelSecondaryActions]]"
               as="action">
-            <gr-button title$="[[action.title]]"
+            <gr-button
+                title$="[[action.title]]"
+                has-tooltip="[[_computeHasTooltip(action.title)]]"
                 primary$="[[action.__primary]]"
                 data-action-key$="[[action.__key]]"
                 data-action-type$="[[action.__type]]"
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 e3220a8..3f967c8 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
@@ -385,7 +385,7 @@
 
     ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
-      this._loading = false;
+      this._handleLoadingComplete();
     },
 
     reload() {
@@ -398,7 +398,7 @@
         if (!revisionActions) { return; }
 
         this.revisionActions = revisionActions;
-        this._loading = false;
+        this._handleLoadingComplete();
       }).catch(err => {
         this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
         this._loading = false;
@@ -406,6 +406,10 @@
       });
     },
 
+    _handleLoadingComplete() {
+      Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
+    },
+
     _changeChanged() {
       this.reload();
     },
@@ -1332,6 +1336,7 @@
           name: action.label,
           id: `${key}-${action.__type}`,
           action,
+          tooltip: action.title,
         };
       });
     },
@@ -1377,5 +1382,9 @@
     _handleStopEditTap() {
       this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
     },
+
+    _computeHasTooltip(title) {
+      return !!title;
+    },
   });
 })();
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 9d51339..ff9e547 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
@@ -29,7 +29,7 @@
 
 <test-fixture id="element">
   <template>
-    <gr-change-metadata></gr-change-metadata>
+    <gr-change-metadata mutable="true"></gr-change-metadata>
   </template>
 </test-fixture>
 
@@ -46,37 +46,55 @@
 
     const sectionSelectors = [
       'section.assignee',
-      'section.labelStatus',
       'section.strategy',
       'section.topic',
     ];
 
+    const labels = {
+      CI: {
+        all: [
+          {value: 1, name: 'user 2', _account_id: 1},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': 'Don\'t submit as-is',
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      },
+    };
+
     const getStyle = function(selector, name) {
       return window.getComputedStyle(
           Polymer.dom(element.root).querySelector(selector))[name];
     };
 
+    function createElement() {
+      const element = fixture('element');
+      element.change = {labels, status: 'NEW'};
+      element.revision = {};
+      return element;
+    }
+
     setup(() => {
       sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        deleteVote() { return Promise.resolve({ok: true}); },
+      });
       stub('gr-change-metadata', {
-        _computeShowLabelStatus() { return true; },
         _computeShowReviewersByState() { return true; },
-        ready() {
-          this.change = {labels: [], status: 'NEW'};
-          this.serverConfig = {};
-        },
       });
     });
 
     teardown(() => {
-      Gerrit._pluginsPending = -1;
-      Gerrit._allPluginsPromise = undefined;
       sandbox.restore();
     });
 
     suite('by default', () => {
       setup(done => {
-        element = fixture('element');
+        element = createElement();
         flush(done);
       });
 
@@ -89,6 +107,7 @@
 
     suite('with plugin style', () => {
       setup(done => {
+        Gerrit._resetPlugins();
         const pluginHost = fixture('plugin-host');
         pluginHost.config = {
           plugin: {
@@ -99,7 +118,7 @@
             ],
           },
         };
-        element = fixture('element');
+        element = createElement();
         const importSpy = sandbox.spy(element.$.externalStyle, '_import');
         Gerrit.awaitPluginsLoaded().then(() => {
           Promise.all(importSpy.returnValues).then(() => {
@@ -114,5 +133,38 @@
         });
       }
     });
+
+    suite('label updates', () => {
+      let plugin;
+      let labelChangeStub;
+
+      setup(done => {
+        Gerrit.install(p => plugin = p, '0.1',
+            new URL('test/plugin.html?' + Math.random(),
+                    window.location.href).toString());
+        sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+        Gerrit._setPluginsPending([]);
+        element = createElement();
+        sandbox.stub(element, '_computeCanDeleteVote').returns(true);
+
+        labelChangeStub = sandbox.stub();
+        plugin.changeMetadata().onLabelsChanged(labelChangeStub);
+        flush(done);
+      });
+
+      test('labels changed callback', done => {
+        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);
+        // Wait for fake rest API response.
+        flush(() => {
+          assert.equal(labelChangeStub.callCount, 2);
+          assert.equal(labelChangeStub.args[1][0]['CI'].all.length, 1);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index eae9d2e..e5728d8 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
@@ -28,9 +28,11 @@
 <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">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-change-requirements/gr-change-requirements.html">
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
@@ -49,7 +51,7 @@
         display: block;
       }
       .title {
-        color: #666;
+        color: var(--deemphasized-text-color);
         font-family: var(--font-family-bold);
         max-width: 20em;
         word-break: break-word;
@@ -93,26 +95,13 @@
       .negative {
         background-color: var(--vote-color-negative);
       }
-      .labelStatus .value {
-        max-width: 9em;
-      }
-      .labelStatus li {
-        list-style-type: disc;
-      }
       .webLink {
         display: block;
       }
-      #missingLabels {
-        padding-left: 1.5em;
-      }
-
       /* CSS Mixins should be applied last. */
       section.assignee {
         @apply --change-metadata-assignee;
       }
-      section.labelStatus {
-        @apply --change-metadata-label-status;
-      }
       section.strategy {
         @apply --change-metadata-strategy;
       }
@@ -233,13 +222,17 @@
       <section>
         <span class="title">Project</span>
         <span class="value">
-          <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+          <a href$="[[_computeProjectURL(change.project)]]">
+            <gr-limited-text limit="40" text="[[change.project]]"></gr-limited-text>
+          </a>
         </span>
       </section>
       <section>
         <span class="title">Branch</span>
         <span class="value">
-          <a href$="[[_computeBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
+          <a href$="[[_computeBranchURL(change.project, change.branch)]]">
+            <gr-limited-text limit="40" text="[[change.branch]]"></gr-limited-text>
+          </a>
         </span>
       </section>
       <section>
@@ -316,12 +309,12 @@
         </section>
       </template>
       <template is="dom-repeat"
-          items="[[_computeLabelNames(change.labels)]]" as="labelName">
+          items="[[_computeLabelNames(labels)]]" as="labelName">
         <section>
           <span class="title">[[labelName]]</span>
           <span class="value">
             <template is="dom-repeat"
-                items="[[_computeLabelValues(labelName, change.labels.*)]]"
+                items="[[_computeLabelValues(labelName, labels.*)]]"
                 as="label">
               <div class="labelValueContainer">
                 <span>
@@ -344,26 +337,11 @@
           </span>
         </section>
       </template>
-      <template is="dom-if" if="[[_showLabelStatus]]">
-        <section class="labelStatus">
-          <span class="title">Label Status</span>
+      <template is="dom-if" if="[[_showRequirements]]">
+        <section class="requirementsStatus">
+          <span class="title">Submit Status</span>
           <span class="value">
-            <div hidden$="[[!_isWip]]">
-              Work in progress
-            </div>
-            <div hidden$="[[!_showMissingLabels(missingLabels)]]">
-              [[_computeMissingLabelsHeader(missingLabels)]]
-              <ul id="missingLabels">
-                <template
-                    is="dom-repeat"
-                    items="[[missingLabels]]">
-                  <li>[[item]]</li>
-                </template>
-              </ul>
-            </div>
-            <div hidden$="[[_showMissingRequirements(missingLabels, _isWip)]]">
-              Ready to submit
-            </div>
+            <gr-change-requirements change="[[change]]"></gr-change-requirements>
           </span>
         </section>
       </template>
@@ -379,6 +357,7 @@
         </span>
       </section>
       <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
         <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
         <gr-endpoint-param name="revision" value="[[revision]]"></gr-endpoint-param>
       </gr-endpoint-decorator>
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 8947e0e..aaada4f 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
@@ -42,10 +42,13 @@
     properties: {
       /** @type {?} */
       change: Object,
+      labels: {
+        type: Object,
+        notify: true,
+      },
       /** @type {?} */
       revision: Object,
       commitInfo: Object,
-      missingLabels: Array,
       mutable: Boolean,
       /**
        * @type {{ note_db_enabled: string }}
@@ -69,9 +72,9 @@
         type: Boolean,
         computed: '_computeShowReviewersByState(serverConfig)',
       },
-      _showLabelStatus: {
+      _showRequirements: {
         type: Boolean,
-        computed: '_computeShowLabelStatus(change)',
+        computed: '_computeShowRequirements(change)',
       },
 
       _assignee: Array,
@@ -98,9 +101,14 @@
 
     observers: [
       '_changeChanged(change)',
+      '_labelsChanged(change.labels)',
       '_assigneeChanged(_assignee.*)',
     ],
 
+    _labelsChanged(labels) {
+      this.labels = Object.assign({}, labels) || null;
+    },
+
     _changeChanged(change) {
       this._assignee = change.assignee ? [change.assignee] : [];
     },
@@ -243,17 +251,22 @@
     },
 
     _computeTopicReadOnly(mutable, change) {
-      return !mutable || !change.actions.topic || !change.actions.topic.enabled;
+      return !mutable ||
+          !change.actions ||
+          !change.actions.topic ||
+          !change.actions.topic.enabled;
     },
 
     _computeHashtagReadOnly(mutable, change) {
       return !mutable ||
+          !change.actions ||
           !change.actions.hashtags ||
           !change.actions.hashtags.enabled;
     },
 
     _computeAssigneeReadOnly(mutable, change) {
       return !mutable ||
+          !change.actions ||
           !change.actions.assignee ||
           !change.actions.assignee.enabled;
     },
@@ -272,6 +285,19 @@
       return !!serverConfig.note_db_enabled;
     },
 
+    _computeShowRequirements(change) {
+      if (change.status !== this.ChangeStatus.NEW) {
+        // TODO(maximeg) change this to display the stored
+        // requirements, once it is implemented server-side.
+        return false;
+      }
+      const hasRequirements = !!change.requirements &&
+          Object.keys(change.requirements).length > 0;
+      const hasLabels = !!change.labels &&
+          Object.keys(change.labels).length > 0;
+      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
@@ -283,7 +309,9 @@
      *     change-metadata section is modifiable by the current user.
      */
     _computeCanDeleteVote(reviewer, mutable) {
-      if (!mutable) { return false; }
+      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) {
@@ -313,6 +341,7 @@
             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) {
@@ -320,38 +349,24 @@
                       label[key]._account_id === accountID) {
                     // Remove special label field, keeping change label values
                     // in sync with the backend.
-                    this.set(['change.labels', labelName, key], null);
+                    this.change.labels[labelName][key] = null;
+                    wasChanged = true;
                   }
                 }
-                this.splice(['change.labels', labelName, 'all'], i, 1);
+                this.change.labels[labelName].all.splice(i, 1);
+                wasChanged = true;
                 break;
               }
             }
+            if (wasChanged) {
+              this.notifyPath('change.labels');
+            }
           }).catch(err => {
             target.disabled = false;
             return;
           });
     },
 
-    _computeShowLabelStatus(change) {
-      const isNewChange = change.status === this.ChangeStatus.NEW;
-      const hasLabels = Object.keys(change.labels).length > 0;
-      return isNewChange && hasLabels;
-    },
-
-    _computeMissingLabelsHeader(missingLabels) {
-      return 'Needs label' +
-          (missingLabels.length > 1 ? 's' : '') + ':';
-    },
-
-    _showMissingLabels(missingLabels) {
-      return !!missingLabels.length;
-    },
-
-    _showMissingRequirements(missingLabels, workInProgress) {
-      return workInProgress || this._showMissingLabels(missingLabels);
-    },
-
     _computeProjectURL(project) {
       return Gerrit.Nav.getUrlForProjectChanges(project);
     },
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 68b838f..1330451 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
@@ -65,6 +65,43 @@
           'Rebase Always');
     });
 
+    test('computed fields requirements', () => {
+      assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
+      assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
+
+      // No labels and no requirements: submit status is useless
+      assert.isFalse(element._computeShowRequirements({
+        status: 'NEW',
+        labels: {},
+      }));
+
+      // Work in Progress: submit status should be present
+      assert.isTrue(element._computeShowRequirements({
+        status: 'NEW',
+        labels: {},
+        work_in_progress: true,
+      }));
+
+      // We have at least one reason to display Submit Status
+      assert.isTrue(element._computeShowRequirements({
+        status: 'NEW',
+        labels: {
+          Verified: {
+            approved: false,
+          },
+        },
+        requirements: [],
+      }));
+      assert.isTrue(element._computeShowRequirements({
+        status: 'NEW',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'OK',
+        }],
+      }));
+    });
+
     test('show strategy for open change', () => {
       element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
       flushAsynchronousOperations();
@@ -92,26 +129,6 @@
       assert.isTrue(hasCc());
     });
 
-    test('computes submit status', () => {
-      let showMissingLabels = false;
-      sandbox.stub(element, '_showMissingLabels', () => {
-        return showMissingLabels;
-      });
-      assert.isFalse(element._showMissingRequirements(null, false));
-      assert.isTrue(element._showMissingRequirements(null, true));
-      showMissingLabels = true;
-      assert.isTrue(element._showMissingRequirements(null, false));
-    });
-
-    test('show missing labels', () => {
-      let missingLabels = [];
-      assert.isFalse(element._showMissingLabels(missingLabels));
-      missingLabels = ['test'];
-      assert.isTrue(element._showMissingLabels(missingLabels));
-      missingLabels.push('test2');
-      assert.isTrue(element._showMissingLabels(missingLabels));
-    });
-
     test('weblinks use Gerrit.Nav interface', () => {
       const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
           .returns([{name: 'stubb', url: '#s'}]);
@@ -168,20 +185,6 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    test('determines whether to show "Ready to Submit" label', () => {
-      const showMissingSpy = sandbox.spy(element, '_showMissingRequirements');
-      element.missingLabels = ['bojack'];
-      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {
-        test: {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-        },
-      }};
-      flushAsynchronousOperations();
-      assert.isTrue(showMissingSpy.called);
-    });
-
     test('_computeShowUploader test for uploader', () => {
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
@@ -523,36 +526,26 @@
         assert.isFalse(button.hasAttribute('hidden'));
       });
 
-      test('deletes votes', done => {
-        const deleteStub = sandbox.stub(element.$.restAPI, 'deleteVote')
-            .returns(Promise.resolve({ok: true}));
+      test('deletes votes', () => {
+        const deleteResponse = Promise.resolve({ok: true});
+        const deleteStub = sandbox.stub(
+            element.$.restAPI, 'deleteVote').returns(deleteResponse);
 
-        element.change.removable_reviewers = [
-          {
-            _account_id: 1,
-            name: 'bojack',
-          },
-        ];
+        element.change.removable_reviewers = [{
+          _account_id: 1,
+          name: 'bojack',
+        }];
         element.change.labels.test.recommended = {_account_id: 1};
         element.mutable = true;
-        flushAsynchronousOperations();
         const chip = element.$$('gr-account-chip');
         const button = chip.$$('gr-button');
-
-        const spliceStub = sandbox.stub(element, 'splice', (path, index,
-            length) => {
-          assert.isFalse(chip.disabled);
-          assert.deepEqual(path, ['change.labels', 'test', 'all']);
-          assert.equal(index, 0);
-          assert.equal(length, 1);
-          assert.notOk(element.change.labels.test.recommended);
-          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-          spliceStub.restore();
-          done();
-        });
-
         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'));
+        });
       });
 
       test('changing topic', () => {
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
new file mode 100644
index 0000000..536b943
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.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.
+-->
+
+<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">
+
+<dom-module id="gr-change-requirements">
+  <template strip-whitespace>
+    <style include="shared-styles">
+      .status {
+        display: inline-block;
+        font-weight: initial;
+        text-align: center;
+        width: 1em;
+      }
+      .unsatisfied .status {
+        color: #FFA62F;
+      }
+      .satisfied .status {
+        color: #388E3C;
+      }
+      .requirement {
+        padding: .1em .3em;
+      }
+      .requirementContainer:not(:first-of-type) {
+        margin-top: .25em;
+      }
+      .labelName, .changeIsWip {
+        font-weight: bold;
+      }
+    </style>
+    <template is="dom-if" if="[[_showWip]]">
+      <div class="requirement unsatisfied changeIsWip">
+        <span class="status">⧗</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">[[item.status]]</span>
+          Label <span class="labelName">[[item.label]]</span>
+        </div>
+      </template>
+    </template>
+    <template
+        is="dom-repeat"
+        items="[[requirements]]">
+      <div class$="requirement [[_computeRequirementClass(item.satisfied)]]">
+        <span class="status">
+          [[_computeRequirementStatus(item.satisfied)]]
+        </span>
+        [[item.fallback_text]]
+      </div>
+    </template>
+  </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
new file mode 100644
index 0000000..884e9cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -0,0 +1,102 @@
+/**
+ * @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-change-requirements',
+
+    properties: {
+      /** @type {?} */
+      change: Object,
+      requirements: {
+        type: Array,
+        computed: '_computeRequirements(change)',
+      },
+      labels: {
+        type: Array,
+        computed: '_computeLabels(change)',
+      },
+      _showWip: {
+        type: Boolean,
+        computed: '_computeShowWip(change)',
+      },
+      _showLabels: {
+        type: Boolean,
+        computed: '_computeShowLabelStatus(change)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    _computeShowLabelStatus(change) {
+      return change.status === this.ChangeStatus.NEW;
+    },
+
+    _computeShowWip(change) {
+      return change.work_in_progress;
+    },
+
+    _computeRequirements(change) {
+      const _requirements = [];
+
+      if (change.requirements) {
+        for (const requirement of change.requirements) {
+          requirement.satisfied = requirement.status === 'OK';
+          _requirements.push(requirement);
+        }
+      }
+
+      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 status = this._computeRequirementStatus(obj.approved);
+        const style = this._computeRequirementClass(obj.approved);
+        _labels.push({label, status, style});
+      }
+
+      return _labels;
+    },
+
+    _computeRequirementClass(requirementStatus) {
+      if (requirementStatus) {
+        return 'satisfied';
+      } else {
+        return 'unsatisfied';
+      }
+    },
+
+    _computeRequirementStatus(requirementStatus) {
+      if (requirementStatus) {
+        return '✓';
+      } else {
+        return '⧗';
+      }
+    },
+  });
+})();
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
new file mode 100644
index 0000000..560a33d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -0,0 +1,129 @@
+<!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-change-requirements</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-change-requirements.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-requirements></gr-change-requirements>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('computed fields', () => {
+      assert.isTrue(element._computeShowLabelStatus({status: 'NEW'}));
+      assert.isFalse(element._computeShowLabelStatus({status: 'MERGED'}));
+      assert.isFalse(element._computeShowLabelStatus({status: 'ABANDONED'}));
+
+      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._computeRequirementStatus(true), '✓');
+      assert.equal(element._computeRequirementStatus(false), '⧗');
+    });
+
+    test('properly converts satisfied labels', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {
+          Verified: {
+            approved: true,
+          },
+        },
+        requirements: [],
+      };
+      flushAsynchronousOperations();
+
+      const labelName = element.$$('.satisfied .labelName');
+      assert.ok(labelName);
+      assert.isFalse(labelName.hasAttribute('hidden'));
+      assert.equal(labelName.innerHTML, 'Verified');
+    });
+
+    test('properly converts unsatisfied labels', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {
+          Verified: {
+            approved: false,
+          },
+        },
+      };
+      flushAsynchronousOperations();
+
+      const labelName = element.$$('.unsatisfied .labelName');
+      assert.ok(labelName);
+      assert.isFalse(labelName.hasAttribute('hidden'));
+      assert.equal(labelName.innerHTML, 'Verified');
+    });
+
+    test('properly displays Work In Progress', () => {
+      element.change = {
+        status: 'NEW',
+        labels: {},
+        requirements: [],
+        work_in_progress: true,
+      };
+      flushAsynchronousOperations();
+
+      const changeIsWip = element.$$('.changeIsWip.unsatisfied');
+      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',
+        labels: {},
+        requirements: [{
+          fallback_text: 'Resolve all comments',
+          status: 'OK',
+        }],
+      };
+      flushAsynchronousOperations();
+
+      const satisfiedRequirement = element.$$('.satisfied');
+      assert.ok(satisfiedRequirement);
+      assert.isFalse(satisfiedRequirement.hasAttribute('hidden'));
+
+      // 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');
+    });
+  });
+</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 1827ef6..0688435 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
@@ -22,6 +22,7 @@
 <link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.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="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../edit/gr-edit-constants.html">
@@ -58,13 +59,13 @@
         background-color: var(--view-background-color);
       }
       .container.loading {
-        color: #666;
+        color: var(--deemphasized-text-color);
         padding: 1em var(--default-horizontal-margin);
       }
       .header {
         align-items: center;
-        background-color: #fafafa;
-        border-bottom: 1px solid #ddd;
+        background-color: var(--table-header-background-color);
+        border-bottom: 1px solid var(--border-color);
         display: flex;
         padding: .55em var(--default-horizontal-margin);
         z-index: 99;  /* Less than gr-overlay's backdrop */
@@ -119,7 +120,7 @@
         padding: 0 var(--default-horizontal-margin);
       }
       .changeId {
-        color: #666;
+        color: var(--deemphasized-text-color);
         font-family: var(--font-family);
         margin-top: 1em;
       }
@@ -128,7 +129,7 @@
         padding-right: 1em;
       }
       .changeMetadata {
-        border-right: 1px solid #ddd;
+        border-right: 1px solid var(--border-color);
         font-size: .95rem;
         padding: 1em 0;
       }
@@ -189,7 +190,7 @@
       }
       hr {
         border: 0;
-        border-top: 1px solid #ddd;
+        border-top: 1px solid var(--border-color);
         height: 0;
         margin-bottom: 1em;
       }
@@ -241,14 +242,14 @@
         margin-right: -5px;
       }
       paper-tabs {
-        background-color: #fafafa;
-        border-top: 1px solid #ddd;
+        background-color: var(--table-header-background-color);
+        border-top: 1px solid var(--border-color);
         height: 3rem;
-        --paper-tabs-selection-bar-color: var(--color-link);
+        --paper-tabs-selection-bar-color: var(--link-color);
       }
       paper-tab {
         max-width: 15rem;
-        --paper-tab-ink: var(--color-link);
+        --paper-tab-ink: var(--link-color);
       }
       gr-thread-list,
       gr-messages-list {
@@ -406,7 +407,6 @@
               revision="[[_selectedRevision]]"
               commit-info="[[_commitInfo]]"
               server-config="[[_serverConfig]]"
-              missing-labels="[[_missingLabels]]"
               mutable="[[_loggedIn]]"
               parent-is-current="[[_parentIsCurrent]]"
               on-show-reply-dialog="_handleShowReplyDialog">
@@ -425,7 +425,7 @@
                       id="replyBtn"
                       class="reply"
                       hidden$="[[!_loggedIn]]"
-                      secondary
+                      primary
                       disabled="[[_replyDisabled]]"
                       on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
               </div>
@@ -616,6 +616,7 @@
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-comment-api id="commentAPI"></gr-comment-api>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-change-view.js"></script>
 </dom-module>
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 e999911..29ffec8 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
@@ -174,11 +174,6 @@
       },
       _loading: Boolean,
       /** @type {?} */
-      _missingLabels: {
-        type: Array,
-        computed: '_computeMissingLabels(_change.labels)',
-      },
-      /** @type {?} */
       _projectConfig: Object,
       _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
@@ -225,6 +220,7 @@
         observer: '_updateToggleContainerClass',
       },
       _parentIsCurrent: Boolean,
+      _submitEnabled: Boolean,
 
       /** @type {?} */
       _mergeable: {
@@ -383,18 +379,6 @@
       this._editingCommitMessage = false;
     },
 
-    _computeMissingLabels(labels) {
-      const missingLabels = [];
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
-        const obj = labels[label];
-        if (!obj.optional && !obj.approved) {
-          missingLabels.push(label);
-        }
-      }
-      return missingLabels;
-    },
-
     _computeChangeStatusChips(change, mergeable) {
       // Show no chips until mergeability is loaded.
       if (mergeable === null || mergeable === undefined) { return []; }
@@ -402,6 +386,7 @@
       const options = {
         includeDerived: true,
         mergeable: !!mergeable,
+        submitEnabled: this._submitEnabled,
       };
       return this.changeStatuses(change, options);
     },
@@ -618,6 +603,10 @@
         return;
       }
 
+      if (value.changeNum && value.project) {
+        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      }
+
       const patchChanged = this._patchRange &&
           (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
           (this._patchRange.patchNum !== value.patchNum ||
@@ -1102,6 +1091,10 @@
             } else {
               this._latestCommitMessage = null;
             }
+
+            // Update the submit enabled based on current revision.
+            this._submitEnabled = this._isSubmitEnabled(currentRevision);
+
             const lineHeight = getComputedStyle(this).lineHeight;
 
             // Slice returns a number as a string, convert to an int.
@@ -1130,6 +1123,11 @@
           });
     },
 
+    _isSubmitEnabled(currentRevision) {
+      return !!(currentRevision.actions && currentRevision.actions.submit &&
+          currentRevision.actions.submit.enabled);
+    },
+
     _getEdit() {
       return this.$.restAPI.getChangeEdit(this._changeNum, true);
     },
@@ -1212,8 +1210,10 @@
 
       this._reloadComments();
 
+      let reloadPromise;
+
       if (this._patchRange.patchNum) {
-        return Promise.all([
+        reloadPromise = Promise.all([
           this._reloadPatchNumDependentResources(),
           detailCompletes,
         ]).then(() => {
@@ -1224,7 +1224,7 @@
         });
       } else {
         // The patch number is reliant on the change detail request.
-        return detailCompletes.then(() => {
+        reloadPromise = detailCompletes.then(() => {
           this.$.fileList.reload();
           if (!this._latestCommitMessage) {
             this._getLatestCommitMessage();
@@ -1232,6 +1232,10 @@
           return this._getMergeability();
         });
       }
+
+      return reloadPromise.then(() => {
+        this.$.reporting.changeDisplayed();
+      });
     },
 
     /**
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 f377277..a2a2de7 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
@@ -464,22 +464,6 @@
       assert.equal(statusChips.length, 2);
     });
 
-    test('_computeMissingLabels', () => {
-      let labels = {};
-      assert.equal(element._computeMissingLabels(labels).length, 0);
-      labels = {test: {}};
-      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
-      labels.test.approved = true;
-      assert.equal(element._computeMissingLabels(labels).length, 0);
-      labels.test.approved = false;
-      labels.test.optional = true;
-      assert.equal(element._computeMissingLabels(labels).length, 0);
-      labels.test.optional = false;
-      labels.test2 = {};
-      assert.deepEqual(element._computeMissingLabels(labels),
-          ['test', 'test2']);
-    });
-
     test('diff preferences open when open-diff-prefs is fired', () => {
       const overlayOpenStub = sandbox.stub(element.$.fileList,
           'openDiffPrefs');
@@ -540,6 +524,14 @@
       assert.isTrue(element._parentIsCurrent);
     });
 
+    test('_isSubmitEnabled', () => {
+      assert.isFalse(element._isSubmitEnabled({}));
+      assert.isFalse(element._isSubmitEnabled({actions: {}}));
+      assert.isFalse(element._isSubmitEnabled({actions: {submit: {}}}));
+      assert.isTrue(element._isSubmitEnabled(
+          {actions: {submit: {enabled: true}}}));
+    });
+
     test('_updateRebaseAction sets _parentIsCurrent on no rebase', () => {
       const currentRevisionActions = {
         cherrypick: {
@@ -1603,8 +1595,6 @@
         fireEdit = () => {
           element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
         };
-        sandbox.stub(element.$.metadata, '_computeShowLabelStatus');
-        sandbox.stub(element.$.metadata, '_computeLabelNames');
         navigateToChangeStub.restore();
 
         element._change = {revisions: {rev1: {_number: 1}}};
@@ -1656,7 +1646,6 @@
     });
 
     test('_handleStopEditTap', done => {
-      sandbox.stub(element.$.metadata, '_computeShowLabelStatus');
       sandbox.stub(element.$.metadata, '_computeLabelNames');
       navigateToChangeStub.restore();
       sandbox.stub(element, 'computeLatestPatchNum').returns(1);
@@ -1729,5 +1718,18 @@
         });
       });
     });
+
+    test('_paramsChanged sets in projectLookup', () => {
+      sandbox.stub(element.$.relatedChanges, 'reload');
+      sandbox.stub(element, '_reload').returns(Promise.resolve());
+      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+      element._paramsChanged({
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: 101,
+        project: 'test-project',
+      });
+      assert.isTrue(setStub.calledOnce);
+      assert.isTrue(setStub.calledWith(101, 'test-project'));
+    });
   });
 </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 e0362c8..a3d1ffa 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
@@ -36,7 +36,7 @@
         word-wrap: break-word;
       }
       .file {
-        border-top: 1px solid #ddd;
+        border-top: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
         margin: 10px 0 3px;
         padding: 10px 0 5px;
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 c086b9a..e420312 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
@@ -46,7 +46,7 @@
         width: 73ch; /* Add a char to account for the border. */
 
         --iron-autogrow-textarea {
-          border: 1px solid #cdcdcd;
+          border: 1px solid var(--border-color);
           box-sizing: border-box;
           font-family: var(--monospace-font-family);
         }
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 8ccf15b..07d9b83 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
@@ -41,7 +41,7 @@
         width: 73ch; /* Add a char to account for the border. */
 
         --iron-autogrow-textarea {
-          border: 1px solid #cdcdcd;
+          border: 1px solid var(--border-color);
           box-sizing: border-box;
           font-family: var(--monospace-font-family);
         }
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 122f1f9..3dc556d 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
@@ -41,12 +41,12 @@
         text-decoration: none;
       }
       .patchInfoOldPatchSet.patchInfo-header {
-        background-color: #fff9c4;
+        background-color: var(--emphasis-color);
       }
       .patchInfo-header {
         align-items: center;
-        background-color: #fafafa;
-        border-top: 1px solid #ddd;
+        background-color: var(--table-header-background-color);
+        border-top: 1px solid var(--border-color);
         display: flex;
         padding: 6px var(--default-horizontal-margin);
       }
@@ -105,7 +105,7 @@
         display: none;
       }
       gr-linked-chip {
-        --linked-chip-text-color: black;
+        --linked-chip-text-color: var(--primary-text-color);
       }
       .expanded #collapseBtn,
       .openFile .fileViewActions {
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 41818d1..da7a16a 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
@@ -44,7 +44,7 @@
       }
       .row {
         align-items: center;
-        border-top: 1px solid #ddd;
+        border-top: 1px solid var(--border-color);
         display: flex;
         min-height: 2.25em;
         padding: .2em var(--default-horizontal-margin) .2em calc(var(--default-horizontal-margin) - .35rem);
@@ -89,8 +89,7 @@
         cursor: pointer;
       }
       .file-row.expanded {
-        background-color: #eeeeee;
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
         position: -webkit-sticky;
         position: sticky;
         top: 0;
@@ -99,14 +98,14 @@
         z-index: 1;
       }
       .file-row:hover {
-        background-color: #eeeeee;
+        background-color: var(--hover-background-color);
       }
-      .row {
-        /* Needed to provide a spacer for the selected cursor. */
-        border-left: .35rem solid transparent;
+      .file-row.selected {
+        background-color: var(--selection-background-color);
       }
-      .row.selected {
-        border-left: .35rem solid var(--color-link);
+      .file-row.expanded,
+      .file-row.expanded:hover {
+        background-color: var(--expanded-background-color);
       }
       .path {
         cursor: pointer;
@@ -173,13 +172,13 @@
         text-align: right;
       }
       .warning {
-        color: #666;
+        color: var(--deemphasized-text-color);
       }
       input.show-hide {
         display: none;
       }
       label.show-hide {
-        color: var(--color-link);
+        color: var(--link-color);
         cursor: pointer;
         display: block;
         font-size: var(--font-size-small);
@@ -204,7 +203,7 @@
         width: 15em;
       }
       .reviewed label {
-        color: var(--color-link);
+        color: var(--link-color);
         opacity: 0;
         justify-content: flex-end;
         width: 100%;
@@ -225,7 +224,7 @@
         display: none;
       }
       .reviewedLabel {
-        color: rgba(0, 0, 0, .54);
+        color: var(--deemphasized-text-color);
         margin-right: 1em;
         opacity: 0;
       }
@@ -250,7 +249,7 @@
           display: block;
         }
         .row.selected {
-          background-color: #fff;
+          background-color: var(--view-background-color);
         }
         .stats {
           display: none;
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 91c9006..c978281 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
@@ -32,6 +32,7 @@
     D: 'Deleted',
     R: 'Renamed',
     W: 'Rewritten',
+    U: 'Unchanged',
   };
 
   const Defs = {};
@@ -97,8 +98,10 @@
         value: GrFileListConstants.FilesExpandedState.NONE,
         notify: true,
       },
+      _filesByPath: Object,
       _files: {
         type: Array,
+        computed: '_computeFiles(_filesByPath, changeComments, patchRange)',
         observer: '_filesChanged',
         value() { return []; },
       },
@@ -137,6 +140,7 @@
         type: Boolean,
         computed: '_shouldHideBinaryChangeTotals(_patchChange)',
       },
+
       _shownFiles: {
         type: Array,
         computed: '_computeFilesShown(numFilesShown, _files.*)',
@@ -229,8 +233,8 @@
       this.collapseAllDiffs();
       const promises = [];
 
-      promises.push(this._getFiles().then(files => {
-        this._files = files;
+      promises.push(this._getFiles().then(filesByPath => {
+        this._filesByPath = filesByPath;
       }));
       promises.push(this._getLoggedIn().then(loggedIn => {
         return this._loggedIn = loggedIn;
@@ -449,11 +453,30 @@
     },
 
     _getFiles() {
-      return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
+      return this.$.restAPI.getChangeOrEditFiles(
           this.changeNum, this.patchRange);
     },
 
     /**
+     * The closure compiler doesn't realize this.specialFilePathCompare is
+     * valid.
+     * @suppress {checkTypes}
+     */
+    _normalizeChangeFilesResponse(response) {
+      if (!response) { return []; }
+      const paths = Object.keys(response).sort(this.specialFilePathCompare);
+      const files = [];
+      for (let i = 0; i < paths.length; i++) {
+        const info = response[paths[i]];
+        info.__path = paths[i];
+        info.lines_inserted = info.lines_inserted || 0;
+        info.lines_deleted = info.lines_deleted || 0;
+        files.push(info);
+      }
+      return files;
+    },
+
+    /**
      * Handle all events from the file list dom-repeat so event handleers don't
      * have to get registered for potentially very long lists.
      */
@@ -748,6 +771,18 @@
           'gr-icons:expand-less' : 'gr-icons:expand-more';
     },
 
+    _computeFiles(filesByPath, changeComments, patchRange) {
+      const commentedPaths = changeComments.getPaths(patchRange);
+      const files = Object.assign({}, filesByPath);
+      Object.keys(commentedPaths).forEach(commentedPath => {
+        if (files.hasOwnProperty(commentedPath)) {
+          return;
+        }
+        files[commentedPath] = {status: 'U'};
+      });
+      return this._normalizeChangeFilesResponse(files);
+    },
+
     _computeFilesShown(numFilesShown, files) {
       const filesShown = files.base.slice(0, numFilesShown);
       this.fire('files-shown-changed', {length: filesShown.length});
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 e2a3bf4..3f656ae 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
@@ -88,6 +88,10 @@
       });
       element.diffPrefs = {};
       element.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
       saveStub = sandbox.stub(element, '_saveReviewedState',
           () => { return Promise.resolve(); });
     });
@@ -98,9 +102,12 @@
 
     test('correct number of files are shown', () => {
       element.fileListIncrement = 300;
-      element._files = _.times(500, i => {
-        return {__path: '/file' + i, lines_inserted: 9};
-      });
+      element._filesByPath = _.range(500)
+          .reduce((_filesByPath, i) => {
+            _filesByPath['/file' + i] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+
       flushAsynchronousOperations();
       assert.equal(
           Polymer.dom(element.root).querySelectorAll('.file-row').length,
@@ -121,23 +128,24 @@
     });
 
     test('calculate totals for patch number', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {
-          __path: 'file_added_in_rev2.txt',
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        'file_added_in_rev2.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-        {
-          __path: 'myfile.txt',
+        'myfile.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-      ];
+      };
+
       assert.deepEqual(element._patchChange, {
         inserted: 2,
         deleted: 2,
@@ -149,11 +157,20 @@
       assert.isFalse(element._hideChangeTotals);
 
       // Test with a commit message that isn't the first file.
-      element._files = [
-        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
-        {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
-      ];
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
       assert.deepEqual(element._patchChange, {
         inserted: 2,
         deleted: 2,
@@ -165,10 +182,17 @@
       assert.isFalse(element._hideChangeTotals);
 
       // Test with no commit message.
-      element._files = [
-        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
-        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
-      ];
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
       assert.deepEqual(element._patchChange, {
         inserted: 2,
         deleted: 2,
@@ -180,10 +204,10 @@
       assert.isFalse(element._hideChangeTotals);
 
       // Test with files missing either lines_inserted or lines_deleted.
-      element._files = [
-        {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
-        {__path: 'myfile.txt', lines_deleted: 1},
-      ];
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {lines_inserted: 1},
+        'myfile.txt': {lines_deleted: 1},
+      };
       assert.deepEqual(element._patchChange, {
         inserted: 1,
         deleted: 1,
@@ -196,11 +220,11 @@
     });
 
     test('binary only files', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
-        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+      };
       assert.deepEqual(element._patchChange, {
         inserted: 0,
         deleted: 0,
@@ -213,13 +237,13 @@
     });
 
     test('binary and regular files', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
-        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
-        {__path: 'myfile.txt', lines_deleted: 5, size_delta: -10, size: 100},
-        {__path: 'myfile2.txt', lines_inserted: 10},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
+        'myfile2.txt': {lines_inserted: 10},
+      };
       assert.deepEqual(element._patchChange, {
         inserted: 10,
         deleted: 5,
@@ -418,11 +442,11 @@
 
     suite('keyboard shortcuts', () => {
       setup(() => {
-        element._files = [
-          {__path: '/COMMIT_MSG'},
-          {__path: 'file_added_in_rev2.txt'},
-          {__path: 'myfile.txt'},
-        ];
+        element._filesByPath = {
+          '/COMMIT_MSG': {},
+          'file_added_in_rev2.txt': {},
+          'myfile.txt': {},
+        };
         element.changeNum = '42';
         element.patchRange = {
           basePatchNum: 'PARENT',
@@ -631,11 +655,11 @@
     });
 
     test('file review status', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG'},
-        {__path: 'file_added_in_rev2.txt'},
-        {__path: 'myfile.txt'},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'file_added_in_rev2.txt': {},
+        'myfile.txt': {},
+      };
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._loggedIn = true;
       element.changeNum = '42';
@@ -677,11 +701,11 @@
     });
 
     test('_handleFileListTap', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG'},
-        {__path: 'f1.txt'},
-        {__path: 'f2.txt'},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -739,9 +763,9 @@
     });
 
     test('checkbox shows/hides diff inline', () => {
-      element._files = [
-        {__path: 'myfile.txt'},
-      ];
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -764,9 +788,9 @@
     });
 
     test('diff mode correctly toggles the diffs', () => {
-      element._files = [
-        {__path: 'myfile.txt'},
-      ];
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -790,16 +814,16 @@
     });
 
     test('expanded attribute not set on path when not expanded', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG'},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
       assert.isNotOk(element.$$('.expanded'));
     });
 
     test('tapping row ignores links', () => {
-      element._files = [
-        {__path: '/COMMIT_MSG'},
-      ];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -824,7 +848,7 @@
 
     test('_togglePathExpanded', () => {
       const path = 'path/to/my/file.txt';
-      element._files = [{__path: path}];
+      element._filesByPath = {[path]: {}};
       const renderSpy = sandbox.spy(element, '_renderInOrder');
       const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
 
@@ -852,7 +876,7 @@
           'handleDiffUpdate');
 
       const path = 'path/to/my/file.txt';
-      element._files = [{__path: path}];
+      element._filesByPath = {[path]: {}};
       element.expandAllDiffs();
       flushAsynchronousOperations();
       assert.isTrue(element._showInlineDiffs);
@@ -894,7 +918,10 @@
     });
 
     test('filesExpanded value updates to correct enum', () => {
-      element._files = [{__path: 'foo.bar'}, {__path: 'baz.bar'}];
+      element._filesByPath = {
+        'foo.bar': {},
+        'baz.bar': {},
+      };
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
           GrFileListConstants.FilesExpandedState.NONE);
@@ -1000,7 +1027,7 @@
     test('_loadingChanged fired from reload in debouncer', done => {
       element.changeNum = 123;
       element.patchRange = {patchNum: 12};
-      element._files = [{__path: 'foo.bar'}];
+      element._filesByPath = {'foo.bar': {}};
 
       element.reload().then(() => {
         assert.isFalse(element._loading);
@@ -1027,7 +1054,7 @@
       const urlStub = sandbox.stub(element, '_computeDiffURL');
       element.change = {_number: 123};
       element.patchRange = {patchNum: undefined, basePatchNum: 'PARENT'};
-      element._files = [{__path: 'foo/bar.cpp'}];
+      element._filesByPath = {'foo/bar.cpp': {}};
       element.editMode = false;
       flush(() => {
         assert.isFalse(urlStub.called);
@@ -1278,23 +1305,21 @@
       });
       element.numFilesShown = 75;
       element.selectedIndex = 0;
-      element._files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {
-          __path: 'file_added_in_rev2.txt',
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_added_in_rev2.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-        {
-          __path: 'myfile.txt',
+        'myfile.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-      ];
+      };
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._loggedIn = true;
       element.changeNum = '42';
@@ -1457,14 +1482,14 @@
     });
 
     test('_openSelectedFile behavior', () => {
-      const _files = element._files;
-      element.set('_files', []);
+      const _filesByPath = element._filesByPath;
+      element.set('_filesByPath', {});
       const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
       // Noop when there are no files.
       element._openSelectedFile();
       assert.isFalse(navStub.called);
 
-      element.set('_files', _files);
+      element.set('_filesByPath', _filesByPath);
       flushAsynchronousOperations();
        // Navigates when a file is selected.
       element._openSelectedFile();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
index ab77bf5..a07c724f 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
@@ -29,8 +29,8 @@
         padding: 4.5em 1em 1em 1em;
       }
       header {
-        background: #fff;
-        border-bottom: 1px solid #cdcdcd;
+        background: var(--view-background-color);
+        border-bottom: 1px solid var(--border-color);
         left: 0;
         padding: 1em;
         position: absolute;
@@ -59,7 +59,7 @@
       }
       ul li {
         border-radius: .2em;
-        background: #eee;
+        background: var(--header-background-color);
         display: inline-block;
         margin: 0 .2em .4em .2em;
         padding: .2em .4em;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
index 54e8ae2..aae21c0 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -39,13 +39,13 @@
         width: 20%;
       }
       .labelMessage {
-        color: #666;
+        color: var(--deemphasized-text-color);
       }
       .placeholder::before {
         content: ' ';
       }
       .selectedValueText {
-        color: rgba(0, 0, 0, .54);
+        color: var(--deemphasized-text-color);
         font-style: italic;
         margin: 0 .5em 0 .5em;
       }
@@ -58,25 +58,25 @@
       gr-button {
         min-width: 40px;
         --gr-button: {
+          background-color: var(--button-background-color, #f5f5f5);
+          color: var(--primary-text-color);
           padding: .2em .85em;
           @apply(--vote-chip-styles);
         }
-        --gr-button-background: var(--button-background-color, #f5f5f5);
-        --gr-button-color: black;
       }
-      iron-selector > gr-button.iron-selected.max {
+      gr-button.iron-selected.max {
         --button-background-color: var(--vote-color-max);
       }
-      iron-selector > gr-button.iron-selected.positive {
+      gr-button.iron-selected.positive {
         --button-background-color: var(--vote-color-positive);
       }
-      iron-selector > gr-button.iron-selected.min {
+      gr-button.iron-selected.min {
         --button-background-color: var(--vote-color-min);
       }
-      iron-selector > gr-button.iron-selected.negative {
+      gr-button.iron-selected.negative {
         --button-background-color: var(--vote-color-negative);
       }
-      iron-selector > gr-button.iron-selected.neutral {
+      gr-button.iron-selected.neutral {
         --button-background-color: var(--vote-color-neutral);
       }
       .placeholder {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 5a2ac1b..a73faa4 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -31,7 +31,7 @@
     <style include="gr-voting-styles"></style>
     <style include="shared-styles">
       :host {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
         display: block;
         position: relative;
         cursor: pointer;
@@ -48,7 +48,7 @@
       }
       .collapsed .contentContainer {
         align-items: baseline;
-        color: #777;
+        color: var(--deemphasized-text-color);
         display: flex;
         white-space: nowrap;
       }
@@ -119,7 +119,7 @@
         position: static;
       }
       .collapsed .author {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         margin-right: .4em;
       }
       .expanded .author {
@@ -127,7 +127,7 @@
         margin-bottom: .4em;
       }
       .date {
-        color: #666;
+        color: var(--deemphasized-text-color);
         position: absolute;
         right: var(--default-horizontal-margin);
         top: 10px;
@@ -138,7 +138,7 @@
       .score {
         border: 1px solid rgba(0,0,0,.12);
         border-radius: 3px;
-        color: #000;
+        color: var(--primary-text-color);
         display: inline-block;
         margin: -.1em 0;
         padding: 0 .1em;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index df635bf..0a7dacc 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -32,9 +32,9 @@
       }
       .header {
         align-items: center;
-        background-color: #fafafa;
-        border-bottom: 1px solid #ddd;
-        border-top: 1px solid #ddd;
+        background-color: var(--table-header-background-color);
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
         min-height: 3.2em;
@@ -47,12 +47,12 @@
         animation: 3s fadeOut;
       }
       @keyframes fadeOut {
-        0% { background-color: #fff9c4; }
-        100% { background-color: #fff; }
+        0% { background-color: var(--emphasis-color); }
+        100% { background-color: var(--view-background-color); }
       }
       #messageControlsContainer {
         align-items: center;
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
         display: flex;
         height: 2.25em;
         justify-content: center;
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 e78a228..e9d8ce8 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
@@ -65,11 +65,11 @@
         display: inline-block;
       }
       .strikethrough {
-        color: #666;
+        color: var(--deemphasized-text-color);
         text-decoration: line-through;
       }
       .status {
-        color: #666;
+        color: var(--deemphasized-text-color);
         font-family: var(--font-family-bold);
         margin-left: .25em;
       }
@@ -99,7 +99,7 @@
         }
         hr {
           border: 0;
-          border-top: 1px solid #ddd;
+          border-top: 1px solid var(--border-color);
           height: 0;
           margin-bottom: 1em;
         }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index af3acc4..eb06b0f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -110,8 +110,6 @@
     });
 
     teardown(() => {
-      Gerrit._pluginsPending = -1;
-      Gerrit._allPluginsPromise = undefined;
       sandbox.restore();
     });
 
@@ -132,12 +130,14 @@
     });
 
     test('lgtm plugin', done => {
+      Gerrit._resetPlugins();
       const pluginHost = fixture('plugin-host');
       pluginHost.config = {
         plugin: {
           js_resource_paths: [],
           html_resource_paths: [
-            new URL('test/plugin.html', window.location.href).toString(),
+            new URL('test/plugin.html?' + Math.random(),
+                window.location.href).toString(),
           ],
         },
       };
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 8225dac..1e37725 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -53,7 +53,7 @@
         max-height: 100%;
       }
       section {
-        border-top: 1px solid #cdcdcd;
+        border-top: 1px solid var(--border-color);
         flex-shrink: 0;
         padding: .5em 1.5em;
         width: 100%;
@@ -82,7 +82,7 @@
         padding-top: .1em;
       }
       .peopleListLabel {
-        color: #666;
+        color: var(--deemphasized-text-color);
         margin-top: .2em;
         min-width: 7em;
         padding-right: .5em;
@@ -120,7 +120,7 @@
         display: block;
       }
       .previewContainer gr-formatted-text {
-        background: #f6f6f6;
+        background: var(--header-background-color);
         padding: 1em;
       }
       .draftsContainer h3 {
@@ -131,12 +131,12 @@
         margin-left: 1em;
       }
       #checkingStatusLabel {
-        color: #444;
+        color: var(--deemphasized-text-color);
         font-style: italic;
       }
       #notLatestLabel,
       #savingLabel {
-        color: red;
+        color: var(--error-text-color);
       }
       #savingLabel {
         display: none;
@@ -145,7 +145,7 @@
         display: inline;
       }
       #pluginMessage {
-        color: #444;
+        color: var(--deemphasized-text-color);
         margin-left: 1em;
         margin-bottom: .5em;
       }
@@ -267,7 +267,7 @@
           <template is="dom-if" if="[[canBeStarted]]">
             <gr-button
                 link
-                tertiary
+                secondary
                 disabled="[[_isState(knownLatestState, 'not-latest')]]"
                 class="action save"
                 has-tooltip
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
index 3a8b2e1..94787e6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
@@ -28,6 +28,6 @@
           replyApi.setLabelValue(label, '+1');
         }
       });
-    }, '0.1', 'http://test.com/plugins/testplugin/static/test.js');
+    });
   </script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index aa3ddea..ab1f55e 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -126,10 +126,16 @@
       if (!change.labels[label].all) { return NaN; }
       const detailed = change.labels[label].all.filter(
           ({_account_id}) => reviewer._account_id === _account_id).pop();
-      if (!detailed || !detailed.hasOwnProperty('permitted_voting_range')) {
+      if (!detailed) {
         return NaN;
       }
-      return detailed.permitted_voting_range.max;
+      if (detailed.hasOwnProperty('permitted_voting_range')) {
+        return detailed.permitted_voting_range.max;
+      } else if (detailed.hasOwnProperty('value')) {
+        // If preset, user can vote on the label.
+        return 0;
+      }
+      return NaN;
     },
 
     _computeReviewerTooltip(reviewer, change) {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 4e085d0..1a406c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -206,7 +206,6 @@
           CC: reviewers,
         },
       };
-      flushAsynchronousOperations();
       assert.equal(element._hiddenReviewerCount, 0);
       assert.equal(element._displayedReviewers.length, 6);
       assert.equal(element._reviewers.length, 6);
@@ -230,7 +229,6 @@
           CC: reviewers,
         },
       };
-      flushAsynchronousOperations();
       assert.equal(element._hiddenReviewerCount, 2);
       assert.equal(element._displayedReviewers.length, 5);
       assert.equal(element._reviewers.length, 7);
@@ -254,7 +252,6 @@
           CC: reviewers,
         },
       };
-      flushAsynchronousOperations();
       assert.equal(element._hiddenReviewerCount, 0);
       assert.equal(element._displayedReviewers.length, 7);
       assert.equal(element._reviewers.length, 7);
@@ -278,14 +275,13 @@
           CC: reviewers,
         },
       };
-      flushAsynchronousOperations();
       assert.equal(element._hiddenReviewerCount, 95);
       assert.equal(element._displayedReviewers.length, 5);
       assert.equal(element._reviewers.length, 100);
       assert.isFalse(element.$$('.hiddenReviewers').hidden);
 
       MockInteractions.tap(element.$$('.hiddenReviewers'));
-      flushAsynchronousOperations();
+
       assert.equal(element._hiddenReviewerCount, 0);
       assert.equal(element._displayedReviewers.length, 100);
       assert.equal(element._reviewers.length, 100);
@@ -303,7 +299,7 @@
                   {_account_id: 7, permitted_voting_range: {max: 1}}],
           },
           FooBar: {
-            all: [{_account_id: 7, permitted_voting_range: {max: 0}}],
+            all: [{_account_id: 7, value: 0}],
           },
         },
         permitted_labels: {
@@ -324,23 +320,13 @@
 
     test('fails gracefully when all is not included', () => {
       const change = {
-        labels: {
-          Foo: {},
-          Bar: {},
-          FooBar: {},
-        },
+        labels: {Foo: {}},
         permitted_labels: {
           Foo: ['-1', ' 0', '+1', '+2'],
-          Bar: ['-1', ' 0', '+1', '+2'],
-          FooBar: ['-1', ' 0'],
         },
       };
       assert.strictEqual(
           element._computeReviewerTooltip({_account_id: 1}, change), '');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 7}, change), '');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 2}, change), '');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index c3e65ab..1ca678932 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -35,9 +35,9 @@
       }
       .header {
         align-items: center;
-        background-color: #fafafa;
-        border-bottom: 1px solid #ddd;
-        border-top: 1px solid #ddd;
+        background-color: var(--table-header-background-color);
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
         display: flex;
         justify-content: left;
         min-height: 3.2em;
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 9a5fea8..bbe2877 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
@@ -27,6 +27,12 @@
     <style include="shared-styles">
       gr-dropdown {
         padding: .5em;
+        --gr-button: {
+          color: var(--header-text-color);
+        }
+        --gr-dropdown-item: {
+          color: var(--header-text-color);
+        }
       }
       gr-avatar {
         height: 2em;
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 e2ef7c1..0b78df5 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
@@ -36,7 +36,7 @@
       }
       header {
         align-items: center;
-        border-bottom: 1px solid #cdcdcd;
+        border-bottom: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
       }
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 7af5fd5..fa0fe52 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
@@ -18,8 +18,10 @@
 
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
 <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="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
 <link rel="import" href="../gr-smart-search/gr-smart-search.html">
@@ -35,7 +37,7 @@
         display: flex;
       }
       .bigTitle {
-        color: var(--primary-text-color);
+        color: var(--header-text-color);
         font-size: 1.75rem;
         text-decoration: none;
       }
@@ -68,7 +70,7 @@
         position: relative;
       }
       .linksTitle {
-        color: var(--primary-text-color);
+        color: var(--header-text-color);
         display: inline-block;
         font-family: var(--font-family-bold);
         position: relative;
@@ -95,7 +97,13 @@
       .browse {
         padding: .6em .5em;
       }
+      gr-dropdown {
+        --gr-dropdown-item: {
+          color: var(--header-text-color);
+        }
+      }
       .browse {
+        color: var(--header-text-color);
         /* Same as gr-button */
         margin: 5px 4px;
         text-decoration: none;
@@ -115,13 +123,14 @@
         text-overflow: ellipsis;
       }
       .loginButton {
+        color: var(--header-text-color);
         padding: 1em;
       }
       .dropdown-trigger {
         text-decoration: none;
       }
       .dropdown-content {
-        background-color: #fff;
+        background-color: var(--view-background-color);
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
       }
       @media screen and (max-width: 50em) {
@@ -163,15 +172,11 @@
           </gr-dropdown>
           </li>
         </template>
-        <li>
-          <a
-              class="browse linksTitle"
-              href$="[[_computeRelativeURL('/admin/repos')]]">
-            Browse</a>
-        </li>
       </ul>
       <div class="rightItems">
-        <gr-smart-search id="search" value="{{searchQuery}}"></gr-smart-search>
+        <gr-smart-search
+            id="search"
+            search-query="{{searchQuery}}"></gr-smart-search>
         <gr-endpoint-decorator
             class="hideOnMobile"
             name="header-browse-source"></gr-endpoint-decorator>
@@ -181,6 +186,7 @@
         </div>
       </div>
     </nav>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-main-header.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 3ef6126..42c744f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -93,7 +93,8 @@
       },
       _links: {
         type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _docBaseUrl)',
+        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
+            '_docBaseUrl)',
       },
       _loginURL: {
         type: String,
@@ -106,6 +107,7 @@
     },
 
     behaviors: [
+      Gerrit.AdminNavBehavior,
       Gerrit.BaseUrlBehavior,
       Gerrit.DocsUrlBehavior,
     ],
@@ -149,7 +151,7 @@
       return '//' + window.location.host + this.getBaseUrl() + path;
     },
 
-    _computeLinks(defaultLinks, userLinks, docBaseUrl) {
+    _computeLinks(defaultLinks, userLinks, adminLinks, docBaseUrl) {
       const links = defaultLinks.slice();
       if (userLinks && userLinks.length > 0) {
         links.push({
@@ -165,6 +167,10 @@
           class: 'hideOnMobile',
         });
       }
+      links.push({
+        title: 'Browse',
+        links: adminLinks,
+      });
       return links;
     },
 
@@ -186,10 +192,23 @@
     },
 
     _loadAccount() {
-      return this.$.restAPI.getAccount().then(account => {
+      const promises = [
+        this.$.restAPI.getAccount(),
+        Gerrit.awaitPluginsLoaded(),
+      ];
+
+      return Promise.all(promises).then(result => {
+        const account = result[0];
         this._account = account;
         this.$.accountContainer.classList.toggle('loggedIn', account != null);
         this.$.accountContainer.classList.toggle('loggedOut', account == null);
+
+        return this.getAdminLinks(account,
+            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
+            .then(res => {
+              this._adminLinks = res.links;
+            });
       });
     },
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 1fd0402..5d51546 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -87,15 +87,28 @@
         name: 'Facebook',
         url: 'https://facebook.com',
       }];
+      const adminLinks = [{
+        name: 'Repos',
+        url: '/repos',
+      }];
 
       // When no admin links are passed, it should use the default.
-      assert.deepEqual(element._computeLinks(defaultLinks, []), defaultLinks);
-      assert.deepEqual(
-          element._computeLinks(defaultLinks, userLinks),
+      assert.deepEqual(element._computeLinks(defaultLinks, [], adminLinks),
           defaultLinks.concat({
-            title: 'Your',
-            links: userLinks,
+            title: 'Browse',
+            links: adminLinks,
           }));
+      assert.deepEqual(
+          element._computeLinks(defaultLinks, userLinks, adminLinks),
+          defaultLinks.concat([
+            {
+              title: 'Your',
+              links: userLinks,
+            },
+            {
+              title: 'Browse',
+              links: adminLinks,
+            }]));
     });
 
     test('documentation links', () => {
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 8ab5adf3..73a29a7 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -38,6 +38,25 @@
     CATEGORY: 'exception',
   };
 
+  const TIMER = {
+    CHANGE_DISPLAYED: 'ChangeDisplayed',
+    DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+    DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+    PLUGINS_LOADED: 'PluginsLoaded',
+    STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
+    STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+    STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+    WEB_COMPONENTS_READY: 'WebComponentsReady',
+  };
+
+  const STARTUP_TIMERS = {};
+  STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
+  STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
+  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
+  // WebComponentsReady timer is triggered from gr-router.
+  STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
+
   const INTERACTION_TYPE = 'interaction';
 
   const pending = [];
@@ -81,8 +100,8 @@
       category: String,
 
       _baselines: {
-        type: Array,
-        value() { return {}; },
+        type: Object,
+        value: STARTUP_TIMERS, // Shared across all instances.
       },
     },
 
@@ -91,7 +110,7 @@
     },
 
     now() {
-      return Math.round(10 * window.performance.now()) / 10;
+      return window.performance.now();
     },
 
     reporter(...args) {
@@ -157,13 +176,46 @@
       }
     },
 
+    beforeLocationChanged() {
+      for (const prop of Object.keys(this._baselines)) {
+        delete this._baselines[prop];
+      }
+      this.time(TIMER.CHANGE_DISPLAYED);
+      this.time(TIMER.DASHBOARD_DISPLAYED);
+      this.time(TIMER.DIFF_VIEW_DISPLAYED);
+    },
+
     locationChanged(page) {
       this.reporter(
           NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
     },
 
+    dashboardDisplayed() {
+      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+        this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED);
+      } else {
+        this.timeEnd(TIMER.DASHBOARD_DISPLAYED);
+      }
+    },
+
+    changeDisplayed() {
+      if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
+        this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED);
+      } else {
+        this.timeEnd(TIMER.CHANGE_DISPLAYED);
+      }
+    },
+
+    diffViewDisplayed() {
+      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
+        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED);
+      } else {
+        this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED);
+      }
+    },
+
     pluginsLoaded() {
-      this.timeEnd('PluginsLoaded');
+      this.timeEnd(TIMER.PLUGINS_LOADED);
     },
 
     /**
@@ -177,8 +229,9 @@
      * Finish named timer and report it to server.
      */
     timeEnd(name) {
-      const baseTime = this._baselines[name] || 0;
-      const time = Math.round(this.now() - baseTime) + 'ms';
+      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);
       delete this._baselines[name];
     },
@@ -191,4 +244,5 @@
   window.GrReporting = GrReporting;
   // Expose onerror installation so it would be accessible from tests.
   window.GrReporting._catchErrors = catchErrors;
+  window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
 })();
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 62ef2d6..3965c7d 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
@@ -45,6 +45,7 @@
       sandbox = sinon.sandbox.create();
       clock = sinon.useFakeTimers(NOW_TIME);
       element = fixture('basic');
+      element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
       fakePerformance = {
         navigationStart: 1,
         loadEventEnd: 2,
@@ -53,6 +54,7 @@
           {get() { return fakePerformance; }});
       sandbox.stub(element, 'reporter');
     });
+
     teardown(() => {
       sandbox.restore();
       clock.restore();
@@ -67,6 +69,14 @@
       ));
     });
 
+    test('WebComponentsReady', () => {
+      sandbox.stub(element, 'now').returns(42);
+      element.timeEnd('WebComponentsReady');
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'WebComponentsReady', 42
+      ));
+    });
+
     test('pageLoaded', () => {
       element.pageLoaded();
       assert.isTrue(
@@ -76,20 +86,63 @@
       );
     });
 
+    test('beforeLocationChanged', () => {
+      element._baselines['garbage'] = 'monster';
+      sandbox.stub(element, 'time');
+      element.beforeLocationChanged();
+      assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
+      assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
+      assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
+      assert.isFalse(element._baselines.hasOwnProperty('garbage'));
+    });
+
+    test('changeDisplayed', () => {
+      sandbox.spy(element, 'timeEnd');
+      element.changeDisplayed();
+      assert.isFalse(
+          element.timeEnd.calledWithExactly('ChangeDisplayed'));
+      assert.isTrue(
+          element.timeEnd.calledWithExactly('StartupChangeDisplayed'));
+      element.changeDisplayed();
+      assert.isTrue(element.timeEnd.calledWithExactly('ChangeDisplayed'));
+    });
+
+    test('diffViewDisplayed', () => {
+      sandbox.spy(element, 'timeEnd');
+      element.diffViewDisplayed();
+      assert.isFalse(
+          element.timeEnd.calledWithExactly('DiffViewDisplayed'));
+      assert.isTrue(
+          element.timeEnd.calledWithExactly('StartupDiffViewDisplayed'));
+      element.diffViewDisplayed();
+      assert.isTrue(element.timeEnd.calledWithExactly('DiffViewDisplayed'));
+    });
+
+    test('dashboardDisplayed', () => {
+      sandbox.spy(element, 'timeEnd');
+      element.dashboardDisplayed();
+      assert.isFalse(
+          element.timeEnd.calledWithExactly('DashboardDisplayed'));
+      assert.isTrue(
+          element.timeEnd.calledWithExactly('StartupDashboardDisplayed'));
+      element.dashboardDisplayed();
+      assert.isTrue(element.timeEnd.calledWithExactly('DashboardDisplayed'));
+    });
+
     test('time and timeEnd', () => {
       const nowStub = sandbox.stub(element, 'now').returns(0);
       element.time('foo');
-      nowStub.returns(1);
+      nowStub.returns(1.1);
       element.time('bar');
       nowStub.returns(2);
       element.timeEnd('bar');
-      nowStub.returns(3.123);
+      nowStub.returns(3.511);
       element.timeEnd('foo');
       assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo', '3ms'
+          'timing-report', 'UI Latency', 'foo', 4
       ));
       assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'bar', '1ms'
+          'timing-report', 'UI Latency', 'bar', 1
       ));
     });
 
@@ -105,7 +158,7 @@
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', '42ms'
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42
         ));
       });
 
@@ -117,11 +170,13 @@
 
       test('reports if plugins are loaded', () => {
         Gerrit._arePluginsLoaded.returns(true);
-        element.timeEnd('foo');
+        element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.called);
       });
 
       test('reports cached events preserving order', () => {
+        element.time('foo');
+        element.time('bar');
         Gerrit._arePluginsLoaded.returns(false);
         element.timeEnd('foo');
         Gerrit._arePluginsLoaded.returns(true);
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 f6079a5..8af7301 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -182,9 +182,9 @@
   (function() {
     const reporting = document.createElement('gr-reporting');
 
-    document.onload = function() {
+    window.addEventListener('load', () => {
       reporting.pageLoaded();
-    };
+    });
 
     window.addEventListener('WebComponentsReady', () => {
       reporting.timeEnd('WebComponentsReady');
@@ -199,6 +199,7 @@
         type: Object,
         value: app,
       },
+      _isRedirecting: Boolean,
     },
 
     behaviors: [
@@ -217,6 +218,7 @@
     },
 
     _redirect(url) {
+      this._isRedirecting = true;
       page.redirect(url);
     },
 
@@ -640,13 +642,24 @@
         return;
       }
       page(pattern, this._loadUserMiddleware.bind(this), data => {
-        this.$.reporting.locationChanged(handlerName);
+        this.$.reporting.locationChanged(this._getPageName(handlerName, data));
         const promise = opt_authRedirect ?
           this._redirectIfNotLoggedIn(data) : Promise.resolve();
         promise.then(() => { this[handlerName](data); });
       });
     },
 
+    _getPageName(handlerName, ctx) {
+      switch (handlerName) {
+        case '_handleChangeOrDiffRoute': {
+          const isDiffView = ctx.params[8];
+          return isDiffView ? Gerrit.Nav.View.DIFF : Gerrit.Nav.View.CHANGE;
+        }
+        default:
+          return handlerName;
+      }
+    },
+
     _startRouter() {
       const base = this.getBaseUrl();
       if (base) {
@@ -659,6 +672,14 @@
           params => this._generateWeblinks(params)
       );
 
+      page.exit('*', (ctx, next) => {
+        if (!this._isRedirecting) {
+          this.$.reporting.beforeLocationChanged();
+        }
+        this._isRedirecting = false;
+        next();
+      });
+
       // Middleware
       page((ctx, next) => {
         document.body.scrollTop = 0;
@@ -1316,8 +1337,6 @@
         this._redirect(this._generateUrl(params));
       } else {
         this._setParams(params);
-        this.$.restAPI.setInProjectLookup(params.changeNum,
-            params.project);
       }
     },
 
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 3572e89..e0a7e46 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
@@ -1320,6 +1320,12 @@
             assert.isTrue(normalizeRangeStub.called);
           });
 
+          test('gr-reporting recognizes change page', () => {
+            const ctx = makeParams(null, '');
+            assert.equal(element._getPageName('_handleChangeOrDiffRoute', ctx),
+                Gerrit.Nav.View.CHANGE);
+          });
+
           test('diff view', () => {
             normalizeRangeStub.returns(false);
             sandbox.stub(element, '_generateUrl').returns('foo');
@@ -1337,6 +1343,12 @@
             assert.isFalse(redirectStub.called);
             assert.isTrue(normalizeRangeStub.called);
           });
+
+          test('gr-reporting recognizes diff page', () => {
+            const ctx = makeParams('foo/bar/baz', 'b44');
+            assert.equal(element._getPageName('_handleChangeOrDiffRoute', ctx),
+                Gerrit.Nav.View.DIFF);
+          });
         });
 
         test('_handleDiffEditRoute', () => {
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 b7b2147..39b2f7e 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
@@ -28,8 +28,8 @@
         display: flex;
       }
       gr-autocomplete {
-        background-color: white;
-        border: 1px solid #d1d2d3;
+        background-color: var(--view-background-color);
+        border: 1px solid var(--border-color);
         border-radius: 2px 0 0 2px;
         flex: 1;
         font: inherit;
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 1a6245d..22fd2aa 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
@@ -46,7 +46,7 @@
         width: 73ch; /* Add a char to account for the border. */
 
         --iron-autogrow-textarea {
-          border: 1px solid #cdcdcd;
+          border: 1px solid var(--border-color);
           box-sizing: border-box;
           font-family: var(--monospace-font-family);
         }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
index bdc412e..fb801e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
@@ -24,6 +24,7 @@
     <style include="shared-styles">
       :host {
         display: block;
+        max-width: var(--content-width, 80ch);
         white-space: normal;
       }
       gr-diff-comment-thread + gr-diff-comment-thread {
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 6f597d7..de12ea4 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
@@ -28,7 +28,6 @@
     <style include="shared-styles">
       gr-button {
         margin-left: .5em;
-        --gr-button-color: #212121;
       }
       #actions {
         margin-left: auto;
@@ -94,21 +93,25 @@
           <gr-button
               id="replyBtn"
               link
+              secondary
               class="action reply"
               on-tap="_handleCommentReply">Reply</gr-button>
           <gr-button
               id="quoteBtn"
               link
+              secondary
               class="action quote"
               on-tap="_handleCommentQuote">Quote</gr-button>
           <gr-button
               id="ackBtn"
               link
+              secondary
               class="action ack"
               on-tap="_handleCommentAck">Ack</gr-button>
           <gr-button
               id="doneBtn"
               link
+              secondary
               class="action done"
               on-tap="_handleCommentDone">Done</gr-button>
         </div>
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 f1bce4c..75a67bf 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
@@ -65,7 +65,7 @@
         margin: 0;
       }
       .headerMiddle {
-        color: #666;
+        color: var(--deemphasized-text-color);
         flex: 1;
         overflow: hidden;
       }
@@ -88,7 +88,7 @@
       }
       a.date:link,
       a.date:visited {
-        color: #666;
+        color: var(--deemphasized-text-color);
       }
       .actions {
         display: flex;
@@ -97,7 +97,6 @@
       }
       .action {
         margin-left: 1em;
-        --gr-button-color: #212121;
       }
       .robotActions {
         display: flex;
@@ -169,7 +168,7 @@
         display: none;
       }
       label.show-hide {
-        color: #000;
+        color: var(--primary-text-color);
         cursor: pointer;
         display: block;
         font-size: .8rem;
@@ -214,7 +213,7 @@
       #deleteBtn {
         display: none;
         --gr-button: {
-          color: #666;
+          color: var(--deemphasized-text-color);
           padding: 0;
         }
       }
@@ -249,6 +248,7 @@
         <gr-button
             id="deleteBtn"
             link
+            secondary
             class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
             on-tap="_handleCommentDelete">
           (Delete)
@@ -314,21 +314,35 @@
             </label>
           </div>
           <div class="rightActions">
-            <gr-button link class="action cancel hideOnPublished"
+            <gr-button
+                link
+                secondary
+                class="action cancel hideOnPublished"
                 on-tap="_handleCancel">Cancel</gr-button>
-            <gr-button link class="action discard hideOnPublished"
+            <gr-button
+                link
+                secondary
+                class="action discard hideOnPublished"
                 on-tap="_handleDiscard">Discard</gr-button>
-            <gr-button link class="action edit hideOnPublished"
+            <gr-button
+                link
+                secondary
+                class="action edit hideOnPublished"
                 on-tap="_handleEdit">Edit</gr-button>
-            <gr-button link class="action save hideOnPublished"
-                on-tap="_handleSave"
-                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]">Save
-            </gr-button>
+            <gr-button
+                link
+                secondary
+                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
+                class="action save hideOnPublished"
+                on-tap="_handleSave">Save</gr-button>
           </div>
         </div>
         <div class="robotActions" hidden$="[[!_showRobotActions]]">
           <template is="dom-if" if="[[isRobotComment]]">
-            <gr-button link class="action fix"
+            <gr-button
+                link
+                secondary
+                class="action fix"
                 on-tap="_handleFix"
                 disabled="[[robotButtonDisabled]]">
               Please Fix
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index ae62b2e..c912a16 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -26,11 +26,11 @@
         position: relative;
       }
       .contentWrapper ::content .range {
-        background-color: rgba(255,213,0,0.5);
+        background-color: var(--diff-highlight-range-color);
         display: inline;
       }
       .contentWrapper ::content .rangeHighlight {
-        background-color: rgba(255,255,0,0.5);
+        background-color: var(--diff-highlight-range-hover-color);
         display: inline;
       }
       gr-selection-action-box {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
index be257d4..8251e53 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
@@ -28,7 +28,7 @@
         display: flex;
       }
       gr-button.selected iron-icon {
-        color: var(--color-link);
+        color: var(--link-color);
       }
       iron-icon {
         height: 1.3rem;
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 d4bcef4..68c4d39 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
@@ -45,7 +45,7 @@
         padding: 1em 1.5em;
       }
       .header {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
       }
       .mainContainer {
@@ -65,7 +65,7 @@
         flex: 1;
       }
       .actions {
-        border-top: 1px solid #ddd;
+        border-top: 1px solid var(--border-color);
         display: flex;
         justify-content: flex-end;
       }
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 c7a1d86..1fc99b1 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
@@ -23,6 +23,7 @@
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.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-button/gr-button.html">
 <link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
 <link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
@@ -53,12 +54,12 @@
       gr-diff {
         border: none;
         --diff-container-styles: {
-          border-bottom: 1px solid #eee;
+          border-bottom: 1px solid var(--border-color);
         }
       }
       gr-fixed-panel {
-        background-color: #fff;
-        border-bottom: 1px #eee solid;
+        background-color: var(--view-background-color);
+        border-bottom: 1px solid var(--border-color);
         z-index: 1;
       }
       header,
@@ -102,7 +103,7 @@
         text-decoration: none;
       }
       .loading {
-        color: #777;
+        color: var(--deemphasized-text-color);
         font-size: 2rem;
         height: 100%;
         padding: 1em var(--default-horizontal-margin);
@@ -182,7 +183,7 @@
           vertical-align: -.1em;
         }
         .mobileNavLink {
-          color: #000;
+          color: var(--primary-text-color);
           font-size: 1.5rem;
           font-family: var(--font-family-bold);
           text-decoration: none;
@@ -346,6 +347,7 @@
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="cursor"></gr-diff-cursor>
     <gr-comment-api id="commentAPI"></gr-comment-api>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-diff-view.js"></script>
 </dom-module>
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 e37970a..5df640e 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
@@ -552,6 +552,10 @@
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.DIFF) { return; }
 
+      if (value.changeNum && value.project) {
+        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      }
+
       this.$.diff.lineOfInterest = this._getLineOfInterest(this.params);
       this._initCursor(this.params);
 
@@ -614,7 +618,7 @@
       promises.push(this._getChangeEdit(this._changeNum));
 
       this._loading = true;
-      Promise.all(promises).then(r => {
+      return Promise.all(promises).then(r => {
         const edit = r[4];
         if (edit) {
           this.set('_change.revisions.' + edit.commit.commit, {
@@ -625,7 +629,9 @@
         }
         this._loading = false;
         this.$.diff.comments = this._commentsForDiff;
-        this.$.diff.reload();
+        return this.$.diff.reload();
+      }).then(() => {
+        this.$.reporting.diffViewDisplayed();
       });
     },
 
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 c85ea28..620286b 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
@@ -71,6 +71,23 @@
       sandbox.restore();
     });
 
+    test('params change triggers diffViewDisplayed()', () => {
+      sandbox.stub(element.$.reporting, 'diffViewDisplayed');
+      sandbox.stub(element.$.diff, 'reload').returns(Promise.resolve());
+      sandbox.spy(element, '_paramsChanged');
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element.$.reporting.diffViewDisplayed.calledOnce);
+      });
+    });
+
     test('toggle left diff with a hotkey', () => {
       const toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
@@ -1041,5 +1058,19 @@
         assert.isFalse(isVisible(element.$.reviewed));
       });
     });
+
+    test('_paramsChanged sets in projectLookup', () => {
+      sandbox.stub(element, '_getLineOfInterest');
+      sandbox.stub(element, '_initCursor');
+      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: 101,
+        project: 'test-project',
+        path: '',
+      });
+      assert.isTrue(setStub.calledOnce);
+      assert.isTrue(setStub.calledWith(101, 'test-project'));
+    });
   });
 </script>
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 fc23837..3972751 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -25,7 +25,7 @@
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
-<link rel="import" href="../gr-syntax-themes/gr-theme-default.html">
+<link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
 
 <script src="../../../scripts/hiddenscroll.js"></script>
 
@@ -58,11 +58,11 @@
       }
       table {
         border-collapse: collapse;
-        border-right: 1px solid #ddd;
+        border-right: 1px solid var(--border-color);
         table-layout: fixed;
       }
       .lineNum {
-        background-color: #eee;
+        background-color: var(--header-background-color);
       }
       .image-diff .gr-diff {
         text-align: center;
@@ -83,11 +83,11 @@
       .diff-row.target-row.target-side-right .lineNum.right,
       .diff-row.target-row.unified .lineNum {
         background-color: #BBDEFB;
-        color: #000;
+        color: var(--primary-text-color);
       }
       .blank,
       .content {
-        background-color: #fff;
+        background-color: var(--view-background-color);
       }
       .full-width {
         width: 100%;
@@ -110,7 +110,7 @@
         -ms-user-select: none;
         user-select: none;
 
-        color: #666;
+        color: var(--deemphasized-text-color);
         padding: 0 .5em;
         text-align: right;
       }
@@ -119,9 +119,9 @@
       }
       .content {
         overflow: hidden;
-        /* Set max and min width since setting width on table cells still
-           allows them to shrink. */
-        max-width: var(--content-width, 80ch);
+        /* Set min width since setting width on table cells still
+           allows them to shrink. Do not set max width because
+           CJK (Chinese-Japanese-Korean) glyphs have variable width */
         min-width: var(--content-width, 80ch);
         width: var(--content-width, 80ch);
       }
@@ -164,9 +164,7 @@
       .contextControl gr-button {
         display: inline-block;
         text-decoration: none;
-        --gr-button-color: rgba(0,0,0,.54);
         --gr-button: {
-          font-family: var(--monospace-font-family);
           padding: .2em;
         }
       }
@@ -212,7 +210,7 @@
         display: block;
       }
       .target-row td.blame {
-        background: #eee;
+        background: var(--header-background-color);
       }
       col.blame {
         display: none;
@@ -257,7 +255,7 @@
         background-repeat: repeat-y;
       }
     </style>
-    <style include="gr-theme-default"></style>
+    <style include="gr-syntax-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
       <template
           is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index cfc76b7..3de4284 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -33,12 +33,12 @@
         max-width: 15em;
       }
       .arrow {
-        color: rgba(0,0,0,.7);
+        color: var(--deemphasized-text-color);
         margin: 0 .5em;
       }
       gr-dropdown-list {
         --trigger-style: {
-          color: rgba(0,0,0,.7);
+          color: var(--deemphasized-text-color);
           text-transform: none;
           font-family: var(--font-family);
         }
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
new file mode 100644
index 0000000..3dfbf34
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
@@ -0,0 +1,104 @@
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-syntax-theme">
+  <template>
+    <style>
+      /**
+       * @overview Highlight.js emits the following classes that do not have
+       * styles here:
+       *    subst, symbol, class, function, doctag, meta-string, section, name,
+       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+       *    attribute
+       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+       */
+
+      .contentText {
+        color: var(--syntax-default-color);
+      }
+      .gr-syntax-meta {
+        color: var(--syntax-meta-color);
+      }
+      .gr-syntax-keyword {
+        color: var(--syntax-keyword-color);
+        line-height: 1;
+      }
+      .gr-syntax-number {
+        color: var(--syntax-number-color);
+      }
+      .gr-syntax-selector-class {
+        color: var(--syntax-selector-class-color);
+      }
+      .gr-syntax-variable {
+        color: var(--syntax-variable-color);
+      }
+      .gr-syntax-template-variable {
+        color: var(--syntax-template-variable-color);
+      }
+      .gr-syntax-comment {
+        color: var(--syntax-comment-color);
+      }
+      .gr-syntax-string {
+        color: var(--syntax-string-color);
+      }
+      .gr-syntax-selector-id {
+        color: var(--syntax-selector-id-color);
+      }
+      .gr-syntax-built_in {
+        color: var(--syntax-built_in-color);
+      }
+      .gr-syntax-tag {
+        color: var(--syntax-tag-color);
+      }
+      .gr-syntax-link {
+        color: var(--syntax-link-color);
+      }
+      .gr-syntax-meta-keyword {
+        color: var(--syntax-meta-keyword-color);
+      }
+      .gr-syntax-type {
+        color: var(--syntax-type-color);
+      }
+      .gr-syntax-title {
+        color: var(--syntax-title-color);
+      }
+      .gr-syntax-attr {
+        color: var(--syntax-attr-color);
+      }
+      .gr-syntax-literal { /* XML/HTML Attribute */
+        color: var(--syntax-literal-color);
+      }
+      .gr-syntax-selector-pseudo {
+        color: var(--syntax-selector-pseudo-color);
+      }
+      .gr-syntax-regexp {
+        color: var(--syntax-regexp-color);
+      }
+      .gr-syntax-selector-attr {
+        color: var(--syntax-selector-attr-color);
+      }
+      .gr-syntax-template-tag {
+        color: var(--syntax-template-tag-color);
+      }
+      .gr-syntax-emphasis {
+        font-style: italic;
+      }
+      .gr-syntax-strong {
+        font-weight: 700;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
deleted file mode 100644
index b6a84ad..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
+++ /dev/null
@@ -1,87 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="gr-theme-default">
-  <template>
-    <style>
-      /**
-       * @overview Highlight.js emits the following classes that do not have
-       * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section, name,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
-       *    attribute
-       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
-       */
-
-      .gr-syntax-meta {
-         color: #FF1717;
-      }
-      .gr-syntax-keyword {
-        color: #9E0069;
-        line-height: 1;
-      }
-      .gr-syntax-number,
-      .gr-syntax-selector-class {
-        color: #164;
-      }
-      .gr-syntax-variable {
-        color: black;
-      }
-      .gr-syntax-template-variable {
-        color: #0000C0;
-      }
-      .gr-syntax-comment {
-        color: #3F7F5F;
-      }
-      .gr-syntax-string,
-      .gr-syntax-selector-id {
-        color: #2A00FF;
-      }
-      .gr-syntax-built_in {
-        color: #30a;
-      }
-      .gr-syntax-tag {
-        color: #170;
-      }
-      .gr-syntax-link,
-      .gr-syntax-meta-keyword {
-        color: #219;
-      }
-      .gr-syntax-type {
-        color: var(--color-link);
-      }
-      .gr-syntax-title {
-        color: #0000C0;
-      }
-      .gr-syntax-attr,
-      .gr-syntax-literal { /* XML/HTML Attribute */
-        color: #219;
-      }
-      .gr-syntax-selector-pseudo,
-      .gr-syntax-regexp,
-      .gr-syntax-selector-attr,
-      .gr-syntax-template-tag {
-        color: #FA8602;
-      }
-      .gr-syntax-emphasis {
-        font-style: italic;
-      }
-      .gr-syntax-strong {
-        font-weight: 700;
-      }
-    </style>
-  </template>
-</dom-module>
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 2226383..61a9e69 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
@@ -54,7 +54,7 @@
       }
       gr-autocomplete {
         --gr-autocomplete: {
-          border: 1px solid #d1d2d3;
+          border: 1px solid var(--border-color);
           border-radius: 2px;
           font-size: var(--font-size-normal);
           height: 2em;
@@ -62,7 +62,7 @@
         }
       }
       input {
-        border: 1px solid #d1d2d3;
+        border: 1px solid var(--border-color);
         border-radius: 2px;
         font-size: var(--font-size-normal);
         height: 2em;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
index fd6aeb0..c57a147 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -44,7 +44,7 @@
         --gr-dropdown-item: {
           background-color: transparent;
           border: none;
-          color: var(--color-link);
+          color: var(--link-color);
           font-size: inherit;
           text-transform: uppercase;
         }
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 8939a4f..6597f4a 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
@@ -27,6 +27,7 @@
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.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-default-editor/gr-default-editor.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -38,7 +39,7 @@
       }
       gr-fixed-panel {
         background-color: #ebf5fb;
-        border-bottom: 1px #ddd solid;
+        border-bottom: 1px var(--border-color) solid;
         z-index: 1;
       }
       header,
@@ -61,7 +62,7 @@
         }
       }
       .textareaWrapper {
-        border: 1px solid #ddd;
+        border: 1px solid var(--border-color);
         border-radius: 3px;
         margin: var(--default-horizontal-margin);
       }
@@ -120,6 +121,7 @@
       </gr-endpoint-decorator>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-editor-view.js"></script>
 </dom-module>
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 f2d1d76..46d5180 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
@@ -17,10 +17,13 @@
 (function() {
   'use strict';
 
+  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
   const SAVING_MESSAGE = 'Saving changes...';
   const SAVED_MESSAGE = 'All changes saved';
   const SAVE_FAILED_MSG = 'Failed to save changes';
 
+  const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+
   Polymer({
     is: 'gr-editor-view',
 
@@ -87,6 +90,10 @@
       this._getEditPrefs().then(prefs => { this._prefs = prefs; });
     },
 
+    get storageKey() {
+      return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+    },
+
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
@@ -143,9 +150,19 @@
     },
 
     _getFileData(changeNum, path, patchNum) {
+      const storedContent =
+            this.$.storage.getEditableContentItem(this.storageKey);
+
       return this.$.restAPI.getFileContent(changeNum, path, patchNum)
           .then(res => {
-            this._newContent = res.content || '';
+            if (storedContent && storedContent.message) {
+              this.dispatchEvent(new CustomEvent('show-alert',
+                  {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+
+              this._newContent = storedContent.message;
+            } else {
+              this._newContent = res.content || '';
+            }
             this._content = res.content || '';
 
             // A non-ok response may result if the file does not yet exist.
@@ -162,6 +179,7 @@
     _saveEdit() {
       this._saving = true;
       this._showAlert(SAVING_MESSAGE);
+      this.$.storage.eraseEditableContentItem(this.storageKey);
       return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
           this._newContent).then(res => {
             this._saving = false;
@@ -191,7 +209,15 @@
     },
 
     _handleContentChange(e) {
-      if (e.detail.value) { this.set('_newContent', e.detail.value); }
+      this.debounce('store', () => {
+        const content = e.detail.value;
+        if (content) {
+          this.set('_newContent', e.detail.value);
+          this.$.storage.setEditableContentItem(this.storageKey, content);
+        } else {
+          this.$.storage.eraseEditableContentItem(this.storageKey);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL_MS);
     },
 
     _handleSaveShortcut(e) {
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 193c2ed..4cf25fe 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
@@ -118,14 +118,18 @@
   });
 
   test('reacts to content-change event', () => {
+    const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
       bubbles: true,
       detail: {value: 'new content value'},
     }));
+    element.flushDebouncer('store');
     flushAsynchronousOperations();
 
     assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storeStub.called);
+    assert.equal(storeStub.lastCall.args[1], 'new content value');
   });
 
   suite('edit file content', () => {
@@ -147,6 +151,8 @@
 
     test('file modification and save, !ok response', () => {
       const saveSpy = sandbox.spy(element, '_saveEdit');
+      const eraseStub = sandbox.stub(element.$.storage,
+          'eraseEditableContentItem');
       const alertStub = sandbox.stub(element, '_showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
       element._newContent = newText;
@@ -163,6 +169,7 @@
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
         assert.isFalse(element._saving);
         assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
         assert.deepEqual(saveFileStub.lastCall.args,
@@ -219,6 +226,7 @@
       element._newContent = 'initial';
       element._content = 'initial';
       element._type = 'initial';
+      sandbox.stub(element.$.storage, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
@@ -343,5 +351,37 @@
       });
     });
   });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'old content',
+          }));
+
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'old content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1;
+      element._patchNum = 1;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
 });
 </script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 809ca22..7cfb3b0 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -64,9 +64,10 @@
   <template>
     <style include="shared-styles">
       :host {
+        background-color: var(--view-background-color);
         display: flex;
-        min-height: 100%;
         flex-direction: column;
+        min-height: 100%;
       }
       gr-fixed-panel {
         /**
@@ -82,7 +83,7 @@
       gr-main-header {
         background-color: var(--header-background-color);
         padding: 0 var(--default-horizontal-margin);
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
       }
       gr-main-header.shadow {
         /* Make it obvious for shadow dom testing */
@@ -90,6 +91,7 @@
       }
       footer {
         background-color: var(--footer-background-color);
+        border-top: 1px solid var(--border-color);
         display: flex;
         justify-content: space-between;
         padding: .5rem var(--default-horizontal-margin);
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 3a45575..282dae2 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -98,6 +98,7 @@
 
     observers: [
       '_viewChanged(params.view)',
+      '_paramsChanged(params.*)',
     ],
 
     behaviors: [
@@ -227,15 +228,12 @@
         pathname += '@' + hash;
       }
       this.set('_path', pathname);
-      this._handleSearchPageChange();
     },
 
-    _handleSearchPageChange() {
-      if (!this.params) {
-        return;
-      }
+    _paramsChanged(paramsRecord) {
+      const params = paramsRecord.base;
       const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
-      if (viewsToCheck.includes(this.params.view)) {
+      if (viewsToCheck.includes(params.view)) {
         this.set('_lastSearchPage', location.pathname);
       }
     },
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index f0900aa..fb1b241 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -55,6 +55,8 @@
           });
         },
         getPreferences() { return Promise.resolve({my: []}); },
+        getDiffPreferences() { return Promise.resolve({}); },
+        getEditPreferences() { return Promise.resolve({}); },
         getVersion() { return Promise.resolve(42); },
         probePath() { return Promise.resolve(42); },
       });
@@ -87,7 +89,6 @@
         hash: '#2',
         host: location.host,
       };
-      sandbox.stub(element, '_handleSearchPageChange');
       element._handleLocationChange({detail: curLocation});
 
       flush(() => {
@@ -107,6 +108,13 @@
       });
     });
 
+    test('_paramsChanged sets search page', () => {
+      element._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
+      assert.notOk(element._lastSearchPage);
+      element._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
+      assert.ok(element._lastSearchPage);
+    });
+
     suite('_jumpKeyPressed', () => {
       let navStub;
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
new file mode 100644
index 0000000..eddb52b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
@@ -0,0 +1,22 @@
+<!--
+@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">
+
+<dom-module id="gr-change-metadata-api">
+  <script src="gr-change-metadata-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
new file mode 100644
index 0000000..b550f73
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -0,0 +1,39 @@
+/**
+ * @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(window) {
+  'use strict';
+
+  function GrChangeMetadataApi(plugin) {
+    this._hook = null;
+    this.plugin = plugin;
+  }
+
+  GrChangeMetadataApi.prototype._createHook = function() {
+    this._hook = this.plugin.hook('change-metadata-item');
+  };
+
+  GrChangeMetadataApi.prototype.onLabelsChanged = function(callback) {
+    if (!this._hook) {
+      this._createHook();
+    }
+    this._hook.onAttached(element =>
+        this.plugin.attributeHelper(element).bind('labels', callback));
+    return this;
+  };
+
+  window.GrChangeMetadataApi = GrChangeMetadataApi;
+})(window);
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 982123c..a7cfca3 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
@@ -34,11 +34,13 @@
     _configChanged(config) {
       const plugins = config.plugin;
       const htmlPlugins = plugins.html_resource_paths || [];
-      const jsPlugins = this._handleMigrations(plugins.js_resource_paths || [],
-          htmlPlugins);
+      const jsPlugins =
+          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
       const defaultTheme = config.default_theme;
-      Gerrit._setPluginsCount(
-          jsPlugins.length + htmlPlugins.length + (defaultTheme ? 1 : 0));
+      const pluginsPending =
+          [].concat(jsPlugins, htmlPlugins, defaultTheme || []).map(
+              p => this._urlFor(p));
+      Gerrit._setPluginsPending(pluginsPending);
       if (defaultTheme) {
         // Make theme first to be first to load.
         // Load sync to work around rare theme loading race condition.
@@ -72,7 +74,9 @@
         // onload (second param) needs to be a function. When null or undefined
         // were passed, plugins were not loaded correctly.
         this.importHref(
-            this._urlFor(url), () => {}, Gerrit._pluginInstalled, async);
+            this._urlFor(url), () => {},
+            Gerrit._pluginInstallError.bind(null, `${url} import error`),
+            async);
       }
     },
 
@@ -86,18 +90,20 @@
       const el = document.createElement('script');
       el.defer = true;
       el.src = url;
-      el.onerror = Gerrit._pluginInstalled;
+      el.onerror = Gerrit._pluginInstallError.bind(null, `${url} load error`);
       return document.body.appendChild(el);
     },
 
     _urlFor(pathOrUrl) {
       if (pathOrUrl.startsWith('http')) {
+        // Plugins are loaded from another domain.
         return pathOrUrl;
       }
       if (!pathOrUrl.startsWith('/')) {
         pathOrUrl = '/' + pathOrUrl;
       }
-      return this.getBaseUrl() + pathOrUrl;
+      const {href, pathname} = window.location;
+      return href.split(pathname)[0] + this.getBaseUrl() + pathOrUrl;
     },
   });
 })();
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 ac9e69c..d31825f 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
@@ -36,12 +36,14 @@
   suite('gr-diff tests', () => {
     let element;
     let sandbox;
+    let url;
 
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       sandbox.stub(document.body, 'appendChild');
       sandbox.stub(element, 'importHref');
+      url = window.location.href.split(window.location.pathname)[0];
     });
 
     teardown(() => {
@@ -60,36 +62,43 @@
     });
 
     test('imports relative html plugins from config', () => {
+      sandbox.stub(Gerrit, '_pluginInstallError');
       element.config = {
         plugin: {html_resource_paths: ['foo/bar', 'baz']},
       };
-      assert.equal(element.importHref.firstCall.args[0], '/foo/bar');
-      assert.equal(element.importHref.firstCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.firstCall.args[0], url + '/foo/bar');
       assert.isTrue(element.importHref.firstCall.args[3]);
 
-      assert.equal(element.importHref.secondCall.args[0], '/baz');
-      assert.equal(element.importHref.secondCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.secondCall.args[0], url + '/baz');
       assert.isTrue(element.importHref.secondCall.args[3]);
+
+      assert.equal(Gerrit._pluginInstallError.callCount, 0);
+      element.importHref.firstCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 1);
+      element.importHref.secondCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
 
     test('imports relative html plugins from config with a base url', () => {
+      sandbox.stub(Gerrit, '_pluginInstallError');
       sandbox.stub(element, 'getBaseUrl').returns('/the-base');
       element.config = {
         plugin: {html_resource_paths: ['foo/bar', 'baz']}};
-      assert.equal(element.importHref.firstCall.args[0], '/the-base/foo/bar');
-      assert.equal(element.importHref.firstCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.firstCall.args[0],
+          url + '/the-base/foo/bar');
       assert.isTrue(element.importHref.firstCall.args[3]);
 
-      assert.equal(element.importHref.secondCall.args[0], '/the-base/baz');
-      assert.equal(element.importHref.secondCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.secondCall.args[0],
+          url + '/the-base/baz');
       assert.isTrue(element.importHref.secondCall.args[3]);
+      assert.equal(Gerrit._pluginInstallError.callCount, 0);
+      element.importHref.firstCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 1);
+      element.importHref.secondCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
 
-    test('inportHref is not called with null callback functions', () => {
+    test('importHref is not called with null callback functions', () => {
       const plugins = ['path/to/plugin'];
       element._importHtmlPlugins(plugins);
       assert.isTrue(element.importHref.calledOnce);
@@ -98,6 +107,7 @@
     });
 
     test('imports absolute html plugins from config', () => {
+      sandbox.stub(Gerrit, '_pluginInstallError');
       element.config = {
         plugin: {
           html_resource_paths: [
@@ -108,15 +118,16 @@
       };
       assert.equal(element.importHref.firstCall.args[0],
           'http://example.com/foo/bar');
-      assert.equal(element.importHref.firstCall.args[2],
-          Gerrit._pluginInstalled);
       assert.isTrue(element.importHref.firstCall.args[3]);
 
       assert.equal(element.importHref.secondCall.args[0],
           'https://example.com/baz');
-      assert.equal(element.importHref.secondCall.args[2],
-          Gerrit._pluginInstalled);
       assert.isTrue(element.importHref.secondCall.args[3]);
+      assert.equal(Gerrit._pluginInstallError.callCount, 0);
+      element.importHref.firstCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 1);
+      element.importHref.secondCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
 
     test('adds js plugins from config to the body', () => {
@@ -127,16 +138,18 @@
     test('imports relative js plugins from config', () => {
       sandbox.stub(element, '_createScriptTag');
       element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
-      assert.isTrue(element._createScriptTag.calledWith('/foo/bar'));
-      assert.isTrue(element._createScriptTag.calledWith('/baz'));
+      assert.isTrue(element._createScriptTag.calledWith(url + '/foo/bar'));
+      assert.isTrue(element._createScriptTag.calledWith(url + '/baz'));
     });
 
     test('imports relative html plugins from config with a base url', () => {
       sandbox.stub(element, '_createScriptTag');
       sandbox.stub(element, 'getBaseUrl').returns('/the-base');
       element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
-      assert.isTrue(element._createScriptTag.calledWith('/the-base/foo/bar'));
-      assert.isTrue(element._createScriptTag.calledWith('/the-base/baz'));
+      assert.isTrue(element._createScriptTag.calledWith(
+          url + '/the-base/foo/bar'));
+      assert.isTrue(element._createScriptTag.calledWith(
+          url + '/the-base/baz'));
     });
 
     test('imports absolute html plugins from config', () => {
@@ -156,21 +169,23 @@
     });
 
     test('default theme is loaded with html plugins', () => {
+      sandbox.stub(Gerrit, '_pluginInstallError');
       element.config = {
         default_theme: '/oof',
         plugin: {
           html_resource_paths: ['some'],
         },
       };
-      assert.equal(element.importHref.firstCall.args[0], '/oof');
-      assert.equal(element.importHref.firstCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.firstCall.args[0], url + '/oof');
       assert.isFalse(element.importHref.firstCall.args[3]);
 
-      assert.equal(element.importHref.secondCall.args[0], '/some');
-      assert.equal(element.importHref.secondCall.args[2],
-          Gerrit._pluginInstalled);
+      assert.equal(element.importHref.secondCall.args[0], url + '/some');
       assert.isTrue(element.importHref.secondCall.args[3]);
+      assert.equal(Gerrit._pluginInstallError.callCount, 0);
+      element.importHref.firstCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 1);
+      element.importHref.secondCall.args[2]();
+      assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
index a15dfe4..8d23ea2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -65,7 +65,7 @@
         stub('gr-custom-plugin-header', {
           ready() { customHeader = this; },
         });
-        Gerrit._resolveAllPluginsLoaded();
+        Gerrit._setPluginsPending([]);
       });
 
       test('sets logo and title', done => {
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 7e45abc..fa188d7 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
@@ -38,7 +38,7 @@
         text-align: center;
       }
       .checkboxContainer:hover {
-        outline: 1px solid #ddd;
+        outline: 1px solid var(--border-color);
       }
     </style>
     <div class="gr-form-styles">
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index 6ae8295..0a7433e 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -26,7 +26,7 @@
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
       th {
-        color: #666;
+        color: var(--deemphasized-text-color);
         text-align: left;
       }
       #emailTable .emailColumn {
@@ -46,7 +46,7 @@
         height: auto;
       }
       .preferredControl:hover {
-        outline: 1px solid #d1d2d3;
+        outline: 1px solid var(--border-color);
       }
     </style>
     <div class="gr-form-styles">
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index 4db12ec..79c8a3b 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -37,7 +37,7 @@
         margin-bottom: 1em;
       }
       header {
-        border-bottom: 1px solid #cdcdcd;
+        border-bottom: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
         margin-bottom: 1em;
       }
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 894c894..48b01f6 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
@@ -43,6 +43,9 @@
 <dom-module id="gr-settings-view">
   <template>
     <style include="shared-styles">
+      :host {
+        color: var(--primary-text-color);
+      }
       #newEmailInput {
         width: 20em;
       }
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 558140f..52649db 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
@@ -34,10 +34,10 @@
         text-align: center;
       }
       .notifControl:hover {
-        outline: 1px solid #ddd;
+        outline: 1px solid var(--border-color);
       }
       .projectFilter {
-        color: #777;
+        color: var(--deemphasized-text-color);
         font-style: italic;
         margin-left: 1em;
       }
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 ebbc7f5..f04caaa 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
@@ -31,7 +31,7 @@
       }
       .container {
         align-items: center;
-        background: #eee;
+        background: var(--header-background-color);
         border-radius: .75em;
         display: inline-flex;
         padding: 0 .5em;
@@ -48,7 +48,7 @@
       gr-button.remove {
         --gr-button: {
           border: 0;
-          color: #666;
+          color: var(--deemphasized-text-color);
           font-size: 1.7rem;
           font-weight: normal;
           height: .6em;
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 fe77db2..7fb4cab 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
@@ -20,6 +20,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-avatar/gr-avatar.html">
+<link rel="import" href="../gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-label">
@@ -63,7 +64,7 @@
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
-          <span>([[account.status]])</span>
+          (<gr-limited-text limit="20" text="[[account.status]]"></gr-limited-text>)
         </template>
       </span>
     </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index bea8d27..34b0de6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -28,7 +28,7 @@
         display: inline-block;
       }
       a {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         text-decoration: none;
       }
       gr-account-label {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 0e6a000..b47d5a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -33,7 +33,7 @@
         bottom: 1.25rem;
         border-radius: 3px;
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        color: #fff;
+        color: var(--view-background-color);
         left: 1.25rem;
         padding: 1em 1.5em;
         position: fixed;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index ef9ed4e..4e076a89 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -36,17 +36,24 @@
         list-style: none;
       }
       li {
+        border-bottom: 1px solid var(--border-color);
         cursor: pointer;
         padding: .5em .75em;
       }
+      li:last-of-type {
+        border: none;
+      }
       li:focus {
         outline: none;
       }
+      li:hover {
+        background-color: var(--hover-background-color);
+      }
       li.selected {
-        background-color: #eee;
+        background-color: var(--selection-background-color);
       }
       .dropdown-content {
-        background: #fff;
+        background: var(--dropdown-background-color);
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
     </style>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index 971780e..c289aa3 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -35,7 +35,7 @@
         margin: 0 .25em;
       }
       paper-input:not(.borderless) {
-        border: 1px solid #ddd;
+        border: 1px solid var(--border-color);
       }
       paper-input {
         height: 100%;
@@ -43,7 +43,6 @@
         @apply --gr-autocomplete;
         --paper-input-container: {
           padding: 0;
-          min-width: 15em;
         }
         --paper-input-container-input: {
           font-size: var(--font-size-normal);
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 6dd197d..8fff850 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -27,8 +27,8 @@
     <style include="shared-styles">
       /* general styles for all buttons */
       :host {
-        --background-color: var(--gr-button-background, #fff);
-        --button-color: var(--gr-button-color, var(--color-link));
+        --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);
@@ -46,7 +46,7 @@
         -webkit-font-smoothing: initial;
         align-items: center;
         background-color: var(--background-color);
-        color: var(--button-color);
+        color: var(--text-color);
         display: flex;
         font-family: inherit;
         justify-content: center;
@@ -62,18 +62,26 @@
         ), var(--background-color);
       }
 
-      /* Styles for raised buttons specifically */
-      :host([primary][raised]),
-      :host([secondary][raised]) {
-        --background-color: var(--color-link);
-        --button-color: #fff;
+      :host([primary]) {
+        --background-color: var(--primary-button-background-color);
+        --text-color: var(--primary-button-text-color);
+      }
+      :host([link][primary]) {
+        --text-color: var(--primary-button-background-color);
+      }
+      :host([secondary]) {
+        --background-color: var(--secondary-button-text-color);
+        --text-color: var(--secondary-button-background-color);
+      }
+      :host([link][secondary]) {
+        --text-color: var(--secondary-button-text-color);
       }
 
-      /* Keep below color definition for primary/secondary so that this takes
-       precedence when disabled. */
+      /* Keep below color definition for primary so that this takes precedence
+        when disabled. */
       :host([disabled]) {
-        --background-color: #eaeaea;
-        --button-color: #a8a8a8;
+        --background-color: var(--table-subheader-background-color);
+        --text-color: var(--deemphasized-text-color);
         cursor: default;
       }
 
@@ -86,9 +94,6 @@
       :host([disabled][link]) {
         --background-color: transparent;
       }
-      :host([link][tertiary]) {
-        --button-color: var(--color-link-tertiary);
-      }
 
       /* Styles for the optional down arrow */
       :host:not([down-arrow]) .downArrow {display: none; }
@@ -101,7 +106,7 @@
         transition: border-top-color 200ms;
       }
       :host([down-arrow]) paper-button:hover .downArrow {
-        border-top-color: #666;
+        border-top-color: var(--deemphasized-text-color);
       }
     </style>
     <paper-button
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 6ac16cc7..ca6705e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -31,21 +31,11 @@
         value: false,
         reflectToAttribute: true,
       },
-      raised: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: '_isRaised(link)',
-      },
       loading: {
         type: Boolean,
         value: false,
         reflectToAttribute: true,
       },
-      tertiary: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
       disabled: {
         type: Boolean,
         observer: '_disabledChanged',
@@ -81,10 +71,6 @@
       tabindex: '0',
     },
 
-    _isRaised(isLink) {
-      return !isLink;
-    },
-
     _handleAction(e) {
       if (this.disabled) {
         e.preventDefault();
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 5e56db1..70b2635 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
@@ -28,7 +28,7 @@
         cursor: pointer;
       }
       iron-icon.active {
-        fill: var(--color-link);
+        fill: var(--link-color);
       }
     </style>
     <button aria-label="Change star" on-tap="toggleStar">
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index 2832fa7..8efd309 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -20,7 +20,7 @@
   const ChangeStates = {
     MERGED: 'Merged',
     ABANDONED: 'Abandoned',
-    MERGE_CONFLIGT: 'Merge Conflict',
+    MERGE_CONFLICT: 'Merge Conflict',
     WIP: 'WIP',
     PRIVATE: 'Private',
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index cb21bd2..1656c8e 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -32,7 +32,7 @@
         max-height: 90vh;
       }
       header {
-        border-bottom: 1px solid #cdcdcd;
+        border-bottom: 1px solid var(--border-color);
         flex-shrink: 0;
         font-family: var(--font-family-bold);
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 99492d7..1090fea 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -27,6 +27,7 @@
   <template>
     <style include="shared-styles">
       :host {
+        color: inherit;
         display: inline;
       }
     </style>
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 689119f..7570533 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
@@ -44,7 +44,7 @@
         font-family: var(--font-family-bold);
       }
       li[selected] gr-button {
-        color: #000;
+        color: var(--primary-text-color);
         font-family: var(--font-family-bold);
         text-decoration: none;
       }
@@ -55,8 +55,8 @@
       .commands {
         display: flex;
         flex-direction: column;
-        border-bottom: 1px solid #ddd;
-        border-top: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
         padding: .5em;
       }
       gr-copy-clipboard {
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 ed2586a..0aa9ba6 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
@@ -43,7 +43,7 @@
         padding: 0;
       }
       .dropdown-content {
-        background-color: #fff;
+        background-color: var(--dropdown-background-color);
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
         max-height: 70vh;
         margin-top: 2em;
@@ -63,18 +63,18 @@
           min-height: 0;
           padding: 10px 16px;
         }
-        --paper-item-selected: {
-          background-color: rgba(161,194,250,.12);
-        }
         --paper-item-focused-before: {
-          background-color: #f2f2f2;
+          background-color: var(--selection-background-color);
         }
         --paper-item-focused: {
-          background-color: #f2f2f2;
+          background-color: var(--selection-background-color);
         }
       }
+      paper-item:hover {
+        background-color: var(--hover-background-color);
+      }
       paper-item:not(:last-of-type) {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
       }
       .bottomContent {
         color: rgba(0,0,0,.54);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index 7f6cded..8a70b8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -22,6 +22,7 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-dropdown">
@@ -35,7 +36,7 @@
         width: 100%;
       }
       .dropdown-content {
-        background-color: #fff;
+        background-color: var(--dropdown-background-color);
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
       }
       gr-button {
@@ -52,10 +53,13 @@
       ul {
         list-style: none;
       }
-      ul .accountName {
-        font-family: var(--font-family-bold);
+      .topContent,
+      li {
+        border-bottom: 1px solid var(--border-color);
       }
-      li .accountInfo,
+      li:last-of-type {
+        border: none;
+      }
       li .itemAction {
         cursor: pointer;
         display: block;
@@ -73,17 +77,21 @@
         text-decoration: none;
       }
       li .itemAction:not(.disabled):hover {
-        background-color: #6B82D6;
-        color: #fff;
+        background-color: var(--hover-background-color);
       }
       li:focus,
       li.selected {
-        background-color: #EBF5FB;
+        background-color: var(--selection-background-color);
         outline: none;
       }
+      li:focus .itemAction,
+      li.selected .itemAction {
+        background-color: transparent;
+      }
       .topContent {
         display: block;
         padding: .85em 1em;
+        @apply --gr-dropdown-item;
       }
       .bold-text {
         font-family: var(--font-family-bold);
@@ -125,19 +133,23 @@
               as="link"
               initial-count="75">
             <li tabindex="-1">
-              <span
-                  class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
-                  data-id$="[[link.id]]"
-                  on-tap="_handleItemTap"
-                  hidden$="[[link.url]]"
-                  tabindex="-1">[[link.name]]</span>
-              <a
-                  class="itemAction"
-                  href$="[[_computeLinkURL(link)]]"
-                  rel$="[[_computeLinkRel(link)]]"
-                  target$="[[link.target]]"
-                  hidden$="[[!link.url]]"
-                  tabindex="-1">[[link.name]]</a>
+              <gr-tooltip-content
+                  has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
+                  title$="[[link.tooltip]]">
+                <span
+                    class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
+                    data-id$="[[link.id]]"
+                    on-tap="_handleItemTap"
+                    hidden$="[[link.url]]"
+                    tabindex="-1">[[link.name]]</span>
+                <a
+                    class="itemAction"
+                    href$="[[_computeLinkURL(link)]]"
+                    rel$="[[_computeLinkRel(link)]]"
+                    target$="[[link.target]]"
+                    hidden$="[[!link.url]]"
+                    tabindex="-1">[[link.name]]</a>
+              </gr-tooltip-content>
             </li>
           </template>
         </ul>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index f8edc83..70534f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -282,5 +282,9 @@
       Polymer.dom.flush();
       this._listElements = Polymer.dom(this.root).querySelectorAll('li');
     },
+
+    _computeHasTooltip(tooltip) {
+      return !!tooltip;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index d4d21b0..89b6068 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -134,6 +134,21 @@
       assert.isFalse(tapped.called);
     });
 
+    test('properly sets tooltips', () => {
+      element.items = [
+        {name: 'item one', id: 'foo', tooltip: 'hello'},
+        {name: 'item two', id: 'bar'},
+      ];
+      element.disabledIds = [];
+      flushAsynchronousOperations();
+      const tooltipContents = Polymer.dom(element.root)
+          .querySelectorAll('iron-dropdown li gr-tooltip-content');
+      assert.equal(tooltipContents.length, 2);
+      assert.isTrue(tooltipContents[0].hasTooltip);
+      assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
+      assert.isFalse(tooltipContents[1].hasTooltip);
+    });
+
     suite('keyboard navigation', () => {
       setup(() => {
         element.items = [
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 8addfb6..96066de 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -39,7 +39,7 @@
         font: inherit;
       }
       label {
-        color: #777;
+        color: var(--deemphasized-text-color);
         display: inline-block;
         font-family: var(--font-family-bold);
         overflow: hidden;
@@ -48,14 +48,14 @@
         @apply --label-style;
       }
       label.editable {
-        color: var(--color-link);
+        color: var(--link-color);
         cursor: pointer;
       }
       #dropdown {
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
       .inputContainer {
-        background-color: #fff;
+        background-color: var(--dialog-background-color);
         padding: .8em;
         @apply --input-style;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index ce43031..2c32709 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -93,10 +93,6 @@
       ].join(' ');
     },
 
-    _getScrollY() {
-      return window.scrollY;
-    },
-
     unfloat() {
       if (this.floatingDisabled) {
         return;
@@ -127,26 +123,29 @@
       this._reposition();
     },
 
+    _getElementTop() {
+      return this.getBoundingClientRect().top;
+    },
+
     _reposition() {
       if (!this._headerFloating) {
         return;
       }
       const header = this.$.header;
-      const scrollY = this._topInitial - this._getScrollY();
+      // Since the outer element is relative positioned, can  use its top
+      // to determine how to position the inner header element.
+      const elemTop = this._getElementTop();
       let newTop;
-      if (this.keepOnScroll) {
-        if (scrollY > 0) {
-          // Reposition to imitate natural scrolling.
-          newTop = scrollY;
-        } else {
-          newTop = 0;
-        }
-      } else if (scrollY > -this._headerHeight ||
-          this._topLast < -this._headerHeight) {
-        // Allow to scroll away, but ignore when far behind the edge.
-        newTop = scrollY;
+      if (this.keepOnScroll && elemTop < 0) {
+        // Should stick to the top.
+        newTop = 0;
       } else {
-        newTop = -this._headerHeight;
+        // Keep in line with the outer element.
+        newTop = elemTop;
+      }
+      // Initialize top style if it doesn't exist yet.
+      if (!header.style.top && this._topLast === newTop) {
+        header.style.top = newTop;
       }
       if (this._topLast !== newTop) {
         if (newTop === undefined) {
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
index ec3ebe2..9eac7f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -74,24 +74,27 @@
       };
 
       const emulateScrollY = function(distance) {
-        element._getScrollY.returns(distance);
+        element._getElementTop.returns(element._headerTopInitial - distance);
         element._updateDebounced();
         element.flushDebouncer('scroll');
       };
 
       setup(() => {
         element._headerTopInitial = 10;
-        sandbox.stub(element, '_getScrollY').returns(0);
+        sandbox.stub(element, '_getElementTop')
+            .returns(element._headerTopInitial);
       });
 
       test('scrolls header along with document', () => {
         emulateScrollY(20);
-        assert.equal(getHeaderTop(), '-12px');
+        // No top property is set when !_headerFloating.
+        assert.equal(getHeaderTop(), '');
       });
 
       test('does not stick to the top by default', () => {
         emulateScrollY(150);
-        assert.equal(getHeaderTop(), '-100px');
+        // No top property is set when !_headerFloating.
+        assert.equal(getHeaderTop(), '');
       });
 
       test('sticks to the top if enabled', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index 7ab8f2a..e86ba4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -82,6 +82,7 @@
         if (!el.content) { return; }
 
         el.content.addEventListener('labels-changed', e => {
+          console.log('labels-changed', e.detail);
           handler(e.detail);
         });
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 3819e8a..d8a662e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-admin-api/gr-admin-api.html">
 <link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
+<link rel="import" href="../../plugins/gr-change-metadata-api/gr-change-metadata-api.html">
 <link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
 <link rel="import" href="../../plugins/gr-event-helper/gr-event-helper.html">
 <link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
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 78f9694..42bfc6a 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
@@ -60,9 +60,9 @@
       });
       element = fixture('basic');
       errorStub = sandbox.stub(console, 'error');
-      Gerrit._setPluginsCount(1);
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
+      Gerrit._setPluginsPending([]);
     });
 
     teardown(() => {
@@ -306,7 +306,6 @@
     test('_setPluginsCount', done => {
       stub('gr-reporting', {
         pluginsLoaded() {
-          assert.equal(Gerrit._pluginsPending, 0);
           done();
         },
       });
@@ -324,13 +323,11 @@
     test('_pluginInstalled', done => {
       stub('gr-reporting', {
         pluginsLoaded() {
-          assert.equal(Gerrit._pluginsPending, 0);
           done();
         },
       });
       Gerrit._setPluginsCount(2);
       Gerrit._pluginInstalled();
-      assert.equal(Gerrit._pluginsPending, 1);
       Gerrit._pluginInstalled();
     });
 
@@ -348,10 +345,10 @@
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('install calls _pluginInstalled on error', () => {
-      sandbox.stub(Gerrit, '_pluginInstalled');
+    test('plugin install errors mark plugins as loaded', () => {
+      Gerrit._setPluginsCount(1);
       Gerrit.install(() => {}, '0.0pre-alpha');
-      assert.isTrue(Gerrit._pluginInstalled.calledOnce);
+      return Gerrit.awaitPluginsLoaded();
     });
 
     test('installGwt calls _pluginInstalled', () => {
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 0e65065..57cbc85 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
@@ -17,13 +17,25 @@
 (function(window) {
   'use strict';
 
+  let restApi;
+
+  function getRestApi() {
+    if (!restApi) {
+      restApi = document.createElement('gr-rest-api-interface');
+    }
+    return restApi;
+  }
+
   function GrPluginRestApi(opt_prefix) {
     this.opt_prefix = opt_prefix || '';
-    this._restApi = document.createElement('gr-rest-api-interface');
   }
 
   GrPluginRestApi.prototype.getLoggedIn = function() {
-    return this._restApi.getLoggedIn();
+    return getRestApi().getLoggedIn();
+  };
+
+  GrPluginRestApi.prototype.getVersion = function() {
+    return getRestApi().getVersion();
   };
 
   /**
@@ -34,7 +46,7 @@
    * @return {!Promise}
    */
   GrPluginRestApi.prototype.fetch = function(method, url, opt_payload) {
-    return this._restApi.send(method, this.opt_prefix + url, opt_payload);
+    return getRestApi().send(method, this.opt_prefix + url, opt_payload);
   };
 
   /**
@@ -55,7 +67,7 @@
           }
         });
       } else {
-        return this._restApi.getResponseObject(response);
+        return getRestApi().getResponseObject(response);
       }
     });
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index 80460d6..5983621 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -30,20 +30,23 @@
     let sandbox;
     let getResponseObjectStub;
     let sendStub;
+    let restApiStub;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
       getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
       sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
+      restApiStub = {
+        getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
         getResponseObject: getResponseObjectStub,
-        send(...args) {
-          return sendStub(...args);
-        },
-      });
+        send: sendStub,
+        getLoggedIn: sandbox.stub(),
+        getVersion: sandbox.stub(),
+      };
+      stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
+        a[k] = (...args) => restApiStub[k](...args);
+        return a;
+      }, {}));
       Gerrit._setPluginsCount(1);
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
@@ -121,5 +124,21 @@
         assert.equal('text', err);
       });
     });
+
+    test('getLoggedIn', () => {
+      restApiStub.getLoggedIn.returns(Promise.resolve(true));
+      return instance.getLoggedIn().then(result => {
+        assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+        assert.isTrue(result);
+      });
+    });
+
+    test('getConfig', () => {
+      restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+      return instance.getVersion().then(result => {
+        assert.isTrue(restApiStub.getVersion.calledOnce);
+        assert.equal(result, 'foo bar');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index fbcf21af..60e07e0 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
@@ -20,13 +20,22 @@
   /**
    * Hash of loaded and installed plugins, name to Plugin object.
    */
-  const plugins = {};
+  const _plugins = {};
+
+  /**
+   * Array of plugin URLs to be loaded, name to url.
+   */
+  let _pluginsPending = {};
+
+  let _pluginsPendingCount = -1;
 
   const PANEL_ENDPOINTS_MAPPING = {
     CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
     CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
   };
 
+  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
   let _restAPI;
   const getRestAPI = () => {
     if (!_restAPI) {
@@ -80,6 +89,14 @@
   window.$wnd = window;
 
   function getPluginNameFromUrl(url) {
+    if (!(url instanceof URL)) {
+      try {
+        url = new URL(url);
+      } catch (e) {
+        console.warn(e);
+        return null;
+      }
+    }
     const base = Gerrit.BaseUrlBehavior.getBaseUrl();
     const pathname = url.pathname.replace(base, '');
     // Site theme is server from predefined path.
@@ -223,6 +240,10 @@
     return new GrRepoApi(this);
   };
 
+  Plugin.prototype.changeMetadata = function() {
+    return new GrChangeMetadataApi(this);
+  };
+
   Plugin.prototype.admin = function() {
     return new GrAdminApi(this);
   };
@@ -389,22 +410,26 @@
 
   const Gerrit = window.Gerrit || {};
 
+  let _resolveAllPluginsLoaded = null;
+  let _allPluginsPromise = null;
+
+  Gerrit._endpoints = new GrPluginEndpoints();
+
   // Provide reset plugins function to clear installed plugins between tests.
   const app = document.querySelector('#app');
   if (!app) {
     // No gr-app found (running tests)
     Gerrit._resetPlugins = () => {
-      for (const k of Object.keys(plugins)) {
-        delete plugins[k];
+      _resolveAllPluginsLoaded = null;
+      _allPluginsPromise = null;
+      Gerrit._setPluginsPending([]);
+      Gerrit._endpoints = new GrPluginEndpoints();
+      for (const k of Object.keys(_plugins)) {
+        delete _plugins[k];
       }
     };
   }
 
-  // Number of plugins to initialize, -1 means 'not yet known'.
-  Gerrit._pluginsPending = -1;
-
-  Gerrit._endpoints = new GrPluginEndpoints();
-
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use plugin.getPluginName() instead.');
@@ -424,32 +449,31 @@
   };
 
   Gerrit.install = function(callback, opt_version, opt_src) {
+    // HTML import polyfill adds __importElement pointing to the import tag.
+    const script = document.currentScript &&
+        (document.currentScript.__importElement || document.currentScript);
+    const src = opt_src || (script && (script.src || script.baseURI));
+    const name = getPluginNameFromUrl(src);
+
     if (opt_version && opt_version !== API_VERSION) {
-      console.warn('Only version ' + API_VERSION +
-          ' is supported in PolyGerrit. ' + opt_version + ' was given.');
-      Gerrit._pluginInstalled();
+      Gerrit._pluginInstallError(`Plugin ${name} install error: only version ` +
+          API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
+          ' was given.');
       return;
     }
 
-    const src = opt_src || (document.currentScript &&
-         (document.currentScript.src || document.currentScript.baseURI));
-    const name = getPluginNameFromUrl(new URL(src));
-    const existingPlugin = plugins[name];
+    const existingPlugin = _plugins[name];
     const plugin = existingPlugin || new Plugin(src);
     try {
       callback(plugin);
-      plugins[name] = plugin;
+      if (name) {
+        _plugins[name] = plugin;
+      }
+      if (!existingPlugin) {
+        Gerrit._pluginInstalled(src);
+      }
     } catch (e) {
-      console.warn(`${name} install failed: ${e.name}: ${e.message}`);
-    }
-    // Don't double count plugins that may have an html and js install.
-    // TODO(beckysiegel) remove name check once name issue is resolved.
-    // If there isn't a name, it's due to an issue with the polyfill for
-    // html imports in Safari/Firefox. In this case, other plugin related
-    // features may still be broken, but still make sure to call.
-    // _pluginInstalled.
-    if (!name || !existingPlugin) {
-      Gerrit._pluginInstalled();
+      Gerrit._pluginInstallError(`${e.name}: ${e.message}`);
     }
   };
 
@@ -498,50 +522,85 @@
    * @deprecated best effort support, will be removed with GWT UI.
    */
   Gerrit.installGwt = function(url) {
-    Gerrit._pluginInstalled();
-    const name = getPluginNameFromUrl(new URL(url));
+    const name = getPluginNameFromUrl(url);
     let plugin;
     try {
-      plugin = plugins[name] || new Plugin(url);
+      plugin = _plugins[name] || new Plugin(url);
       plugin.deprecated.install();
+      Gerrit._pluginInstalled(url);
     } catch (e) {
-      console.warn(`${name} install failed: ${e.name}: ${e.message}`);
+      Gerrit._pluginInstallError(`${e.name}: ${e.message}`);
     }
     return plugin;
   };
 
-  Gerrit._allPluginsPromise = null;
-  Gerrit._resolveAllPluginsLoaded = null;
-
   Gerrit.awaitPluginsLoaded = function() {
-    if (!Gerrit._allPluginsPromise) {
+    if (!_allPluginsPromise) {
       if (Gerrit._arePluginsLoaded()) {
-        Gerrit._allPluginsPromise = Promise.resolve();
+        _allPluginsPromise = Promise.resolve();
       } else {
-        Gerrit._allPluginsPromise = new Promise(resolve => {
-          Gerrit._resolveAllPluginsLoaded = resolve;
-        });
+        let timeoutId;
+        _allPluginsPromise =
+          Promise.race([
+            new Promise(resolve => _resolveAllPluginsLoaded = resolve),
+            new Promise(resolve => timeoutId = setTimeout(
+                Gerrit._pluginLoadingTimeout, PLUGIN_LOADING_TIMEOUT_MS)),
+          ]).then(() => clearTimeout(timeoutId));
       }
     }
-    return Gerrit._allPluginsPromise;
+    return _allPluginsPromise;
+  };
+
+  Gerrit._pluginLoadingTimeout = function() {
+    document.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: 'Plugins loading timeout. Check the console for errors.',
+      },
+    }));
+    console.error(`Failed to load plugins: ${Object.keys(_pluginsPending)}`);
+    Gerrit._setPluginsPending([]);
+  };
+
+  Gerrit._setPluginsPending = function(plugins) {
+    _pluginsPending = plugins.reduce((o, url) => {
+      o[getPluginNameFromUrl(url)] = url;
+      return o;
+    }, {});
+    Gerrit._setPluginsCount(plugins.length);
   };
 
   Gerrit._setPluginsCount = function(count) {
-    Gerrit._pluginsPending = count;
+    _pluginsPendingCount = count;
     if (Gerrit._arePluginsLoaded()) {
       document.createElement('gr-reporting').pluginsLoaded();
-      if (Gerrit._resolveAllPluginsLoaded) {
-        Gerrit._resolveAllPluginsLoaded();
+      if (_resolveAllPluginsLoaded) {
+        _resolveAllPluginsLoaded();
       }
     }
   };
 
-  Gerrit._pluginInstalled = function() {
-    Gerrit._setPluginsCount(Gerrit._pluginsPending - 1);
+  Gerrit._pluginInstallError = function(message) {
+    console.log(`Plugin install error: ${message}`);
+    Gerrit._setPluginsCount(_pluginsPendingCount - 1);
+  };
+
+  Gerrit._pluginInstalled = function(url) {
+    const name = getPluginNameFromUrl(url);
+    if (name && !_pluginsPending[name]) {
+      console.warn(`Unexpected plugin from ${url}!`);
+    } else {
+      if (name) {
+        delete _pluginsPending[name];
+        console.log(`Plugin ${name} installed`);
+      } else {
+        console.log(`Plugin installed from ${url}`);
+      }
+      Gerrit._setPluginsCount(_pluginsPendingCount - 1);
+    }
   };
 
   Gerrit._arePluginsLoaded = function() {
-    return Gerrit._pluginsPending === 0;
+    return _pluginsPendingCount === 0;
   };
 
   Gerrit._getPluginScreenName = function(pluginName, screenName) {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index 41988de..5d7a8a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -31,7 +31,7 @@
       }
       .container {
         align-items: center;
-        background: #eee;
+        background: var(--header-background-color);
         border-radius: .75em;
         display: inline-flex;
         padding: 0 .5em;
@@ -45,7 +45,7 @@
       gr-button.remove {
         --gr-button: {
           border: 0;
-          color: #666;
+          color: var(--deemphasized-text-color);
           font-size: 1.7rem;
           font-weight: normal;
           height: .6em;
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 d295770..71999acaf 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
@@ -37,7 +37,7 @@
         display: none;
       }
       a {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         text-decoration: none;
       }
       a:hover {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index e9bfb6d..ea94086 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -23,7 +23,7 @@
   <template>
     <style include="shared-styles">
       :host {
-        background: #fff;
+        background: var(--view-background-color);
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
index 7806b8f..3885497 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
@@ -22,8 +22,8 @@
   <template>
     <style include="shared-styles">
       #nav {
-        background-color: #f5f5f5;
-        border: 1px solid #eee;
+        background-color: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
         border-top: none;
         height: 100%;
         position: absolute;
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 bb9b627..a9fd05c 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
@@ -1051,13 +1051,12 @@
      * @param {Defs.patchRange} patchRange
      * @return {!Promise<!Array<!Object>>}
      */
-    getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
+    getChangeOrEditFiles(changeNum, patchRange) {
       if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
         return this.getChangeEditFiles(changeNum, patchRange).then(res =>
-          this._normalizeChangeFilesResponse(res.files));
+            res.files);
       }
-      return this.getChangeFiles(changeNum, patchRange).then(
-          this._normalizeChangeFilesResponse.bind(this));
+      return this.getChangeFiles(changeNum, patchRange);
     },
 
     /**
@@ -1071,25 +1070,6 @@
       });
     },
 
-    /**
-     * The closure compiler doesn't realize this.specialFilePathCompare is
-     * valid.
-     * @suppress {checkTypes}
-     */
-    _normalizeChangeFilesResponse(response) {
-      if (!response) { return []; }
-      const paths = Object.keys(response).sort(this.specialFilePathCompare);
-      const files = [];
-      for (let i = 0; i < paths.length; i++) {
-        const info = response[paths[i]];
-        info.__path = paths[i];
-        info.lines_inserted = info.lines_inserted || 0;
-        info.lines_deleted = info.lines_deleted || 0;
-        files.push(info);
-      }
-      return files;
-    },
-
     getChangeRevisionActions(changeNum, patchNum) {
       return this._getChangeURLAndFetch(changeNum, '/actions', patchNum)
           .then(revisionActions => {
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 95a35e4..fb20da4 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
@@ -1295,8 +1295,8 @@
       });
     });
 
-    test('getChangeFilesAsSpeciallySortedArray is edit-sensitive', () => {
-      const fn = element.getChangeFilesAsSpeciallySortedArray.bind(element);
+    test('getChangeFilesOrEditFiles is edit-sensitive', () => {
+      const fn = element.getChangeOrEditFiles.bind(element);
       const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
           .returns(Promise.resolve({}));
       const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index e85fe38..cf39b5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -41,7 +41,6 @@
         display: inline-block
       }
       #textarea {
-        background-color: var(--background-color, none);
         width: 100%;
       }
       #hiddenText #emojiSuggestions {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index c70dc8d..a3da7d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -70,10 +70,6 @@
         notify: true,
         observer: '_handleTextChanged',
       },
-      backgroundColor: {
-        type: String,
-        value: '#fff',
-      },
       hideBorder: {
         type: Boolean,
         value: false,
@@ -123,9 +119,6 @@
       if (this.hideBorder) {
         this.$.textarea.classList.add('noBorder');
       }
-      if (this.backgroundColor) {
-        this.updateStyles({'--background-color': this.backgroundColor});
-      }
     },
 
     closeDropdown() {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index 97f8ca4..3a52543 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -25,7 +25,6 @@
 <link rel="import" href="gr-textarea.html">
 
 <script>void(0);</script>
-
 <test-fixture id="basic">
   <template>
     <gr-textarea></gr-textarea>
@@ -60,15 +59,6 @@
       assert.isTrue(element.$.textarea.classList.contains('noBorder'));
     });
 
-    test('background color is set properly', () => {
-      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
-          'rgb(255, 255, 255)');
-      element.backgroundColor = 'pink';
-      element.ready();
-      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
-          'rgb(255, 192, 203)');
-    });
-
     test('emoji selector is not open with the textarea lacks focus', () => {
       element.$.textarea.selectionStart = 1;
       element.$.textarea.selectionEnd = 1;
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
deleted file mode 100644
index 242c04b..0000000
--- a/polygerrit-ui/app/index.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<html lang="en">
-<meta charset="utf-8">
-<meta name="description" content="Gerrit Code Review">
-<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
-
-<!--
-RobotoMono fonts are used in styles/fonts.css
-@see https://github.com/w3c/preload/issues/32 regarding crossorigin
--->
-<link rel="preload" href="/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>
-<link rel="preload" href="/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>
-<link rel="preload" href="/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
-<link rel="preload" href="/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>
-<link rel="preload" href="/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>
-<link rel="preload" href="/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>
-<link rel="stylesheet" href="/styles/fonts.css">
-<link rel="stylesheet" href="/styles/main.css">
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<!--
-  - Content between webcomponents-lite and the load of the main app element
-  - run before polymer-resin is installed so may have security consequences.
-  - Contact your local security engineer if you have any questions, and
-  - CC them on any changes that load content before gr-app.html.
-  -
-  - github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  -->
-<link rel="preload" href="/elements/gr-app.js" as="script" crossorigin="anonymous">
-<link rel="import" href="/elements/gr-app.html">
-
-<body unresolved>
-<gr-app id="app"></gr-app>
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 0d03910..b60aa22 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -65,7 +65,6 @@
     name = name + "_top_sources",
     srcs = [
         "favicon.ico",
-        "index.html",
     ],
   )
 
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 61142cb..9642019 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -22,31 +22,72 @@
   --header-title-content: 'Gerrit';
   --header-icon: none;
   --header-icon-size: 0em;
+  --header-text-color: #000;
   --footer-background-color: var(--header-background-color);
+  --border-color: #ddd;
 
   /* Following are not part of plugin API. */
-  --search-border-color: #ddd;
-  --selection-background-color: #ebf5fb;
-  --default-text-color: #000;
+  --selection-background-color: #f1f5fb;
+  --hover-background-color: #e8effa;
+  --expanded-background-color: #eee;
   --view-background-color: #fff;
   --default-horizontal-margin: 1rem;
+  --deemphasized-text-color: #757575;
   --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --font-family-bold: 'Roboto Medium', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
   --iron-overlay-backdrop: {
     transition: none;
   }
+  --table-header-background-color: #fafafa;
+  --table-subheader-background-color: #eaeaea;
+
+  --dropdown-background-color: #fff;
 
   /* Font sizes */
   --font-size-normal: 1rem;
   --font-size-small: .92rem;
   --font-size-large: 1.076rem;
 
-  /* Follow are a part of the design refresh */
-  --color-link: #2a66d9;
-  --color-link-tertiary: #000;
-  /* 12% darker */
-  --color-button-hover: #0B47BA;
+  --link-color: #2a66d9;
+  --primary-button-background-color: var(--link-color);
+  --primary-button-text-color: #fff;
+  --secondary-button-background-color: #fff;
+  --secondary-button-text-color: #212121;
+  --default-button-background-color: #fff;
+  --default-button-text-color: var(--link-color);
+
+  /* Used for both the old patchset header and for indicating that a particular
+    change message was selected. */
+  --emphasis-color: #fff9c4;
+
+  --error-text-color: red;
+
+  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
+  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+
+  --syntax-default-color: var(--primary-text-color);
+  --syntax-meta-color: #FF1717;
+  --syntax-keyword-color: #9E0069;
+  --syntax-number-color: #164;
+  --syntax-selector-class-color: #164;
+  --syntax-variable-color: black;
+  --syntax-template-variable-color: #0000C0;
+  --syntax-comment-color: #3F7F5F;
+  --syntax-string-color: #2A00FF;
+  --syntax-selector-id-color: #2A00FF;
+  --syntax-built_in-color: #30a;
+  --syntax-tag-color: #170;
+  --syntax-link-color: #219;
+  --syntax-meta-keyword-color: #219;
+  --syntax-type-color: var(--color-link);
+  --syntax-title-color: #0000C0;
+  --syntax-attr-color: #219;
+  --syntax-literal-color: #219;
+  --syntax-selector-pseudo-color: #FA8602;
+  --syntax-regexp-color: #FA8602;
+  --syntax-selector-attr-color: #FA8602;
+  --syntax-template-tag-color: #FA8602;
 }
 @media screen and (max-width: 50em) {
   :root {
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
new file mode 100644
index 0000000..a88f68c
--- /dev/null
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -0,0 +1,48 @@
+<!--
+@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.
+-->
+
+<dom-module id="dashboard-header-styles">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+        height: 9em;
+        width: 100%;
+      }
+      gr-avatar {
+        display: inline-block;
+        height: 7em;
+        left: 1em;
+        margin: 1em;
+        top: 1em;
+        width: 7em;
+      }
+      .info {
+        display: inline-block;
+        padding: 1em;
+        vertical-align: top;
+      }
+      .info > div > span {
+        display: inline-block;
+        font-weight: bold;
+        text-align: right;
+        width: 4em;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 76a8c1b..4f92039 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -22,7 +22,28 @@
       }
       gr-change-list-item,
       tr {
-        border-bottom: 1px solid #ddd;
+        border-top: 1px solid var(--border-color);
+      }
+      gr-change-list-item[selected],
+      gr-change-list-item:focus {
+        background-color: var(--selection-background-color);
+      }
+      /* The border-collapse attribute only works on sibling elements, not
+        cousin elements. So, if we want the table to have a sticky header and
+        have borders between each row, we must disable the border-top on the
+        elements directly below a .topHeader. */
+      .topHeader ~ gr-change-list-item:first-of-type,
+      .topHeader + .groupHeader {
+        border-top: none;
+      }
+      /* Needed to show a border on top of the first gr-change-list-item when a
+        groupHeader exists. Cannot use + selector because of dom-repeats
+        existing in the DOM tree. */
+      .topHeader ~ .groupHeader ~ gr-change-list-item {
+        border-top: 1px solid var(--border-color);
+      }
+      tbody {
+        border-bottom: 1px solid var(--border-color);
       }
       tr.topHeader {
         border: none;
@@ -46,7 +67,7 @@
         font-family: var(--font-family-bold);
       }
       .topHeader th {
-        background-color: #fafafa;
+        background-color: var(--table-header-background-color);
         font-size: var(--font-size-large);
         height: 3rem;
         position: -webkit-sticky;
@@ -57,7 +78,7 @@
       /* :after pseudoelements are used here because borders on sticky table
         headers with a background color are broken. */
       th:after {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid var(--border-color);
         bottom: 0;
         content: '';
         left: 0;
@@ -65,14 +86,14 @@
         width: 100%;
       }
       th.label:after {
-        border-left: 1px solid #ddd;
+        border-left: 1px solid var(--border-color);
         top: 0;
       }
       .groupHeader {
-        background-color: #eaeaea;
+        background-color: var(--table-subheader-background-color);
       }
       .groupHeader a {
-        color: #000;
+        color: var(--primary-text-color);
         text-decoration: none;
       }
       .groupHeader a:hover {
@@ -81,14 +102,12 @@
       .cell {
         height: 2.25rem;
       }
-      .keyboard,
       .star {
         padding: 0;
       }
       gr-change-star {
         vertical-align: middle;
       }
-      .keyboard,
       .branch,
       .star,
       .label,
@@ -101,18 +120,17 @@
       .project {
         white-space: nowrap;
       }
-      .keyboard,
       .star {
         vertical-align: middle;
       }
-      .keyboard {
+      .leftPadding {
         width: 20px;
       }
       .star {
         width: 30px;
       }
       .label {
-        border-left: 1px solid #ddd;
+        border-left: 1px solid var(--border-color);
         text-align: center;
         width: 3rem;
       }
@@ -122,7 +140,7 @@
       .truncatedProject {
         display: none;
       }
-      @media only screen and (max-width: 90em) {
+      @media only screen and (max-width: 100em) {
         .assignee,
         .branch,
         .owner {
@@ -146,12 +164,21 @@
           justify-content: space-between;
           padding: .25em .5em;
         }
+        gr-change-list-item[selected],
+        gr-change-list-item:focus {
+          background-color: var(--view-background-color);
+          border: none;
+          border-top: 1px solid var(--border-color);
+        }
+        gr-change-list-item:hover {
+          background-color: var(--view-background-color);
+        }
         .cell {
           align-items: center;
           display: flex;
         }
         .topHeader,
-        .keyboard,
+        .leftPadding,
         .status,
         .project,
         .branch,
@@ -176,8 +203,8 @@
         }
       }
       @media only screen and (min-width: 1450px) {
-        .project {
-          width: 20em;
+        :host {
+          font-size: 14px;
         }
       }
     </style>
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 0417e91..88c75c8 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -37,7 +37,7 @@
         display: inline-block;
       }
       .gr-form-styles .title {
-        color: #666;
+        color: var(--deemphasized-text-color);
         font-family: var(--font-family-bold);
         padding-right: .5em;
         width: 15em;
@@ -46,7 +46,7 @@
         font-size: var(--font-size-normal);
       }
       .gr-form-styles th {
-        color: #666;
+        color: var(--deemphasized-text-color);
         text-align: left;
         vertical-align: bottom;
       }
@@ -76,7 +76,7 @@
       .gr-form-styles input:not([type="checkbox"]),
       .gr-form-styles select,
       .gr-form-styles textarea {
-        border: 1px solid #d1d2d3;
+        border: 1px solid var(--border-color);
         border-radius: 2px;
         font-size: var(--font-size-normal);
         height: 2em;
@@ -94,7 +94,7 @@
         height: auto;
         min-height: 2em;
         --iron-autogrow-textarea: {
-          border: 1px solid #d1d2d3;
+          border: 1px solid var(--border-color);
           border-radius: 2px;
           box-sizing: border-box;
           font-size: var(--font-size-normal);
@@ -104,7 +104,7 @@
       .gr-form-styles gr-autocomplete {
         border: none;
         --gr-autocomplete: {
-          border: 1px solid #d1d2d3;
+          border: 1px solid var(--border-color);
           border-radius: 2px;
           font-size: var(--font-size-normal);
           height: 2em;
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.html b/polygerrit-ui/app/styles/gr-menu-page-styles.html
index bafcbc6..4adbeda 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.html
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.html
@@ -25,7 +25,12 @@
         margin: 2em auto;
         max-width: 50em;
       }
-      main.table {
+      .mainHeader {
+        margin-left: 14em;
+        padding: 1em 0 1em 2em;
+      }
+      main.table,
+      .mainHeader {
         margin-top: 0;
         margin-right: 0;
         margin-left: 14em;
@@ -36,7 +41,7 @@
         content: ' *';
       }
       .loading {
-        color: #666;
+        color: var(--deemphasized-text-color);
         padding: 1em var(--default-horizontal-margin);
       }
       @media only screen and (max-width: 67em) {
@@ -57,6 +62,10 @@
         main.table {
           margin: 0;
         }
+        .mainHeader {
+          margin-left: 0;
+          padding: .5em 0 .5em 1em;
+        }
       }
     </style>
   </template>
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index 8d8659e..6d62f23 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -49,13 +49,13 @@
         margin: .4em 0;
       }
       .navStyles .selected {
-        background-color: #fff;
+        background-color: var(--view-background-color);
         border-bottom: 1px dotted #808080;
         border-top: 1px dotted #808080;
         font-family: var(--font-family-bold);
       }
       .navStyles a {
-        color: black;
+        color: var(--primary-text-color);
         display: inline-block;
         margin: .4em 0;
       }
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.html b/polygerrit-ui/app/styles/gr-subpage-styles.html
new file mode 100644
index 0000000..098a604
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.html
@@ -0,0 +1,34 @@
+<!--
+@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.
+-->
+<dom-module id="gr-subpage-styles">
+  <template>
+    <style>
+      main {
+        margin: 1em 1em;
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
index 7b4d856..2bcd743 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -26,15 +26,15 @@
         width: 100%;
       }
       .genericList tr.table {
-        border-bottom: 1px solid #eee;
+        border-bottom: 1px solid var(--border-color);
       }
       .genericList td {
         flex-shrink: 0;
         padding: .3em .5em;
       }
       .genericList th {
-        background-color: #ddd;
-        border-bottom: 1px solid #eee;
+        background-color: var(--border-color);
+        border-bottom: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
         padding: .3em .5em;
         text-align: left;
@@ -43,7 +43,7 @@
         background-color: #eee;
       }
       .genericList a {
-        color: var(--default-text-color);
+        color: var(--primary-text-color);
         text-decoration: none;
       }
       .genericList a:hover {
@@ -53,7 +53,7 @@
         width: 70%;
       }
       .genericList .loadingMsg {
-        color: #666;
+        color: var(--deemphasized-text-color);
         display: block;
         padding: 1em var(--default-horizontal-margin);
       }
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 8917fd7..f97408d 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -35,12 +35,13 @@
       }
       input,
       iron-autogrow-textarea {
+        background-color: inherit;
         box-sizing: border-box;
         margin: 0;
         padding: 0;
       }
       a {
-        color: var(--color-link);
+        color: var(--link-color);
       }
       input,
       textarea,
@@ -80,7 +81,7 @@
         font-family: var(--font-family-bold);
       }
       iron-icon {
-        color: #757575;
+        color: var(--deemphasized-text-color);
         --iron-icon-height: 20px;
         --iron-icon-width: 20px;
       }
@@ -89,21 +90,23 @@
         display: none !important;
       }
       .separator {
-        border-left: 1px solid rgba(0, 0, 0, .3);
+        border-left: 1px solid var(--deemphasized-text-color);
         height: 20px;
         margin: 0 8px;
-
       }
       .separator.transparent {
         border-color: transparent;
       }
       paper-toggle-button {
-        --paper-toggle-button-checked-bar-color: var(--color-link);
-        --paper-toggle-button-checked-button-color: var(--color-link);
+        --paper-toggle-button-checked-bar-color: var(--link-color);
+        --paper-toggle-button-checked-button-color: var(--link-color);
       }
       strong {
         font-family: var(--font-family-bold);
       }
+      :host {
+        color: var(--primary-text-color);
+      }
     </style>
   </template>
 </dom-module>
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index 246e7b8..6b3a4d1 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -49,14 +49,9 @@
   (function() {
     setup(() => {
       if (!window.Gerrit) { return; }
-      Gerrit._pluginsPending = -1;
-      Gerrit._allPluginsPromise = undefined;
       if (Gerrit._resetPlugins) {
         Gerrit._resetPlugins();
       }
-      if (Gerrit._endpoints) {
-        Gerrit._endpoints = new GrPluginEndpoints();
-      }
     });
   })();
 </script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index c29e3b5..6cf674a 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -163,6 +163,7 @@
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
     '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-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',
@@ -195,6 +196,7 @@
     'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
     'rest-client-behavior/rest-client-behavior_test.html',
     'gr-access-behavior/gr-access-behavior_test.html',
+    'gr-admin-nav-behavior/gr-admin-nav-behavior_test.html',
     'gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html',
     'gr-change-table-behavior/gr-change-table-behavior_test.html',
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index bc4a4d1..2594ff7 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -20,6 +20,7 @@
 	"encoding/json"
 	"errors"
 	"flag"
+	"github.com/robfig/soy"
 	"io"
 	"io/ioutil"
 	"log"
@@ -36,11 +37,17 @@
 	prod     = flag.Bool("prod", false, "Serve production assets")
 	scheme   = flag.String("scheme", "https", "URL scheme")
 	plugins  = flag.String("plugins", "", "comma seperated plugin paths to serve")
+
+	tofu, _ = soy.NewBundle().
+		AddTemplateFile("../resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy").
+		CompileToTofu()
 )
 
 func main() {
 	flag.Parse()
 
+	http.HandleFunc("/index.html", handleIndex)
+
 	if *prod {
 		http.Handle("/", http.FileServer(http.Dir("dist")))
 	} else {
@@ -63,6 +70,15 @@
 	log.Fatal(http.ListenAndServe(*port, &server{}))
 }
 
+func handleIndex(w http.ResponseWriter, r *http.Request) {
+	var obj = map[string]interface{}{
+		"canonicalPath":      "",
+		"staticResourcePath": "",
+	}
+	w.Header().Set("Content-Type", "text/html")
+	tofu.Render(w, "com.google.gerrit.httpd.raw.Index", obj)
+}
+
 func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
 	if strings.HasSuffix(r.URL.Path, ".html") {
 		w.Header().Set("Content-Type", "text/html")
@@ -211,13 +227,13 @@
 func (_ *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	log.Printf("%s %s %s %s\n", r.Proto, r.Method, r.RemoteAddr, r.URL)
 	for _, prefix := range fePaths {
-		if strings.HasPrefix(r.URL.Path, prefix) {
-			r.URL.Path = "/"
-			log.Println("Redirecting to /")
+		if strings.HasPrefix(r.URL.Path, prefix) || r.URL.Path == "/" {
+			r.URL.Path = "/index.html"
+			log.Println("Redirecting to /index.html")
 			break
 		} else if match := issueNumRE.Find([]byte(r.URL.Path)); match != nil {
-			r.URL.Path = "/"
-			log.Println("Redirecting to /")
+			r.URL.Path = "/index.html"
+			log.Println("Redirecting to /index.html")
 			break
 		}
 	}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index a013140..699dd0e 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -29,12 +29,11 @@
   <meta name="description" content="Gerrit Code Review">{\n}
   <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
 
-  {if $canonicalPath != '' or $versionInfo}
-    <script>
-      {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
-      {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
-    </script>{\n}
-  {/if}
+  <script>
+    window.CLOSURE_NO_DEPS = true;
+    {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
+    {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
+  </script>{\n}
 
   {if $faviconPath}
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/{$faviconPath}">{\n}
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index 1de7eea..6654837 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -19,3 +19,4 @@
 viewConnections = View Connections
 viewPlugins = View Plugins
 viewQueue = View Queue
+viewAccess = View Access
diff --git a/resources/com/google/gerrit/server/mail/Reverted.soy b/resources/com/google/gerrit/server/mail/Reverted.soy
index 09e32ff..fba8744 100644
--- a/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -25,7 +25,7 @@
  * @param fromName
  */
 {template .Reverted kind="text"}
-  {$fromName} has reverted this change.
+  {$fromName} has created a revert of this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
   Change subject: {$change.subject}{\n}
diff --git a/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index 63ad6f0..b7b254e 100644
--- a/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -22,7 +22,7 @@
  */
 {template .RevertedHtml}
   <p>
-    {$fromName} <strong>reverted</strong> this change.
+    {$fromName} has <strong>created a revert</strong> of this change.
   </p>
 
   {if $email.changeUrl}
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 97f291b..f49c881 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -28,6 +28,8 @@
   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]),
@@ -50,7 +52,7 @@
         ":".join(transitive_jar_paths),
         "-d %s" % dir]),
     "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
-    "(cd %s && zip -qr ../%s *)" % (dir, ctx.outputs.zip.basename),
+    "(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,
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 945b8e1..2796f64 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -75,13 +75,15 @@
 
   _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 -r %s bower_components" % renamed_name,
+    "zip -Xr %s bower_components" % renamed_name,
     "cd ..",
     "rm -rf ${TMP}",
   ]))
@@ -153,11 +155,13 @@
     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 -qr ../%s *" %  ctx.outputs.zip.basename
+    "zip -Xqr ../%s *" %  ctx.outputs.zip.basename
   ])
 
   ctx.actions.run_shell(
@@ -232,13 +236,15 @@
     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 -qr $p/%s bower_components/*" % out_zip.path,
+      "zip -Xqr $p/%s bower_components/*" % out_zip.path,
     ]),
     mnemonic="BowerCombine")
 
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index a8ccbee..d6a4c78 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -55,9 +55,11 @@
 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 -9qr ${root}/%s .' % (output.path),
+    'zip -X -9qr ${root}/%s .' % (output.path),
   ])
 
 def _war_impl(ctx):
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index b3ac143..9c36c72 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -17,7 +17,7 @@
 set -eu
 
 # Keep this version in sync with dev-contributing.txt.
-VERSION=${1:-1.3}
+VERSION=${1:-1.5}
 
 case "$VERSION" in
 1.3)