Merge "Add file status tooltip and add declare modified status"
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 2362401..496c205 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -18,12 +18,14 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[client_commands]]Commands
+[[client_commands]]
+=== Commands
 
 link:cmd-cherry-pick.html[gerrit-cherry-pick]::
 	Download and cherry-pick one or more changes (commits).
 
-=== [[client_hooks]]Hooks
+[[client_hooks]]
+=== Hooks
 
 Client hooks can be installed into a local Git repository, improving
 the developer experience when working with a Gerrit Code Review
@@ -47,7 +49,8 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[user_commands]]User Commands
+[[user_commands]]
+=== User Commands
 
 link:cmd-apropos.html[gerrit apropos]::
 	Search Gerrit documentation index.
@@ -85,6 +88,9 @@
 link:cmd-set-project.html[gerrit set-project]::
 	Change a project's settings.
 
+link:cmd-set-project-parent.html[gerrit set-project-parent]::
+	Change the project permissions are inherited from.
+
 link:cmd-set-reviewers.html[gerrit set-reviewers]::
 	Add or remove reviewers on a change.
 
@@ -103,8 +109,8 @@
 git upload-pack::
 	Standard Git server side command for client side `git fetch`.
 
-[[admin_commands]]Administrator Commands
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+[[admin_commands]]
+=== Administrator Commands
 
 link:cmd-close-connection.html[gerrit close-connection]::
 	Close the specified SSH connection.
@@ -178,9 +184,6 @@
 link:cmd-set-members.html[gerrit set-members]::
 	Set group members.
 
-link:cmd-set-project-parent.html[gerrit set-project-parent]::
-	Change the project permissions are inherited from.
-
 link:cmd-show-caches.html[gerrit show-caches]::
 	Display current cache statistics.
 
@@ -205,6 +208,36 @@
 link:cmd-suexec.html[suexec]::
 	Execute a command as any registered user account.
 
+[[trace]]
+=== Trace
+
+For executing SSH commands tracing can be enabled by setting the
+`--trace` and `--trace-id <trace-id>` options. It is recommended to use
+the ID of the issue that is being investigated as trace ID.
+
+----
+  $ ssh -p 29418 review.example.com gerrit create-project --trace --trace-id issue/123 foo/bar
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+----
+  $ ssh -p 29418 review.example.com gerrit create-project --trace foo/bar
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. The trace ID is printed to
+the stderr command output:
+
+----
+  TRACE_ID: 1534174322774-7edf2a7b
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-set-project-parent.txt b/Documentation/cmd-set-project-parent.txt
index 6e2328c..ec5a5c6 100644
--- a/Documentation/cmd-set-project-parent.txt
+++ b/Documentation/cmd-set-project-parent.txt
@@ -20,7 +20,11 @@
 the project to inherit through another one.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group.
+Caller must be a member of the privileged 'Administrators' group
+or, if
+link:config-gerrit.html#receive.allowProjectOwnersToChangeParent[receive.allowProjectOwnersToChangeParent]
+is enabled, be a project owner of the projects that is getting their
+parent updated.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 4c71124..0108a04 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -786,6 +786,7 @@
 +
 * `"change_notes"`: disk storage is disabled by default
 * `"diff_summary"`: default is `1g` (1 GiB of disk space)
+* `"external_ids_map"`: disk storage is disabled by default
 
 +
 If 0 or negative, disk storage for the cache is disabled.
@@ -862,8 +863,10 @@
 cache may temporarily contain 2 entries, but the second one is promptly
 expired.
 +
-It is not recommended to change the attributes of this cache away from
-the defaults.
+It is not recommended to change the in-memory attributes of this cache
+away from the defaults. The cache may be persisted by setting
+`diskLimit`, which is only recommended if cold start performance is
+problematic.
 
 cache `"git_tags"`::
 +
@@ -3667,6 +3670,17 @@
 +
 Default is true.
 
+[[receive.allowProjectOwnersToChangeParent]]receive.allowProjectOwnersToChangeParent::
++
+If true, Gerrit will allow project owners to change the parent of a project.
++
+By default only Gerrit administrators are allowed to change the parent
+of a project. By allowing project owners to change parents, it may
+allow the owner to circumvent certain enforced rules (like important
+BLOCK rules).
++
+Default is false.
+
 [[receive.checkReferencedObjectsAreReachable]]receive.checkReferencedObjectsAreReachable::
 +
 If set to true, Gerrit will validate that all referenced objects that
@@ -3734,6 +3748,14 @@
 +
 Common unit suffixes of 'k', 'm', or 'g' are supported.
 
+[[receive.inheritProjectMaxObjectSizeLimit]]receive.inheritProjectMaxObjectSizeLimit::
++
+Controls whether the project-level link:config-project-config.html[`receive.maxObjectSizeLimit`]
+value is inherited from the parent project. When `true`, the value is
+inherited, otherwise it is not inherited.
++
+Default is false, the value is not inherited.
+
 [[receive.maxTrustDepth]]receive.maxTrustDepth::
 +
 If signed push validation is link:#receive.enableSignedPush[enabled],
@@ -4626,7 +4648,7 @@
 
 [[theme.backgroundColor]]theme.backgroundColor::
 +
-Background color for the page, and major data tables like the all
+_(GWT UI only)_ Background color for the page, and major data tables like the all
 open changes table or the account dashboard. The value must be a
 valid HTML hex color code, or standard color name.
 +
@@ -4634,7 +4656,7 @@
 
 [[theme.topMenuColor]]theme.topMenuColor::
 +
-This is the color of the main menu bar at the top of the page.
+_(GWT UI only)_ This is the color of the main menu bar at the top of the page.
 The value must be a valid HTML hex color code, or standard color
 name.
 +
@@ -4642,53 +4664,52 @@
 
 [[theme.textColor]]theme.textColor::
 +
-Text color for the page, and major data tables like the all
-open changes table or the account dashboard. The value must be a
-valid HTML hex color code, or standard color name.
+_(GWT UI only)_ Text color for the page, and major data tables like the all open
+changes table or the account dashboard. The value must be a valid HTML hex color
+code, or standard color name.
 +
 By default dark grey, `353535`.
 
 [[theme.trimColor]]theme.trimColor::
 +
-Primary color used as a background color behind text.  This is
-the color of the main menu bar at the top, of table headers,
-and of major UI areas that we want to offset from other portions
-of the page.  The value must be a valid HTML hex color code, or
-standard color name.
+_(GWT UI only)_ Primary color used as a background color behind text.  This is
+the color of the main menu bar at the top, of table headers, and of major UI
+areas that we want to offset from other portions of the page.  The value must be
+a valid HTML hex color code, or standard color name.
 +
 By default a light grey, `EEEEEE`.
 
 [[theme.selectionColor]]theme.selectionColor::
 +
-Background color used within a trimColor area to denote the currently
-selected tab, or the background color used in a table to denote the
-currently selected row.  The value must be a valid HTML hex color
-code, or standard color name.
+_(GWT UI only)_ Background color used within a trimColor area to denote the
+currently selected tab, or the background color used in a table to denote the
+currently selected row.  The value must be a valid HTML hex color code, or
+standard color name.
 +
 By default a pale blue, `D8EDF9`.
 
 [[theme.changeTableOutdatedColor]]theme.changeTableOutdatedColor::
 +
-Background color used for patch outdated messages.  The value must be
-a valid HTML hex color code, or standard color name.
+_(GWT UI only)_ Background color used for patch outdated messages.  The value
+must be a valid HTML hex color code, or standard color name.
 +
 By default a shade of red, `F08080`.
 
 [[theme.tableOddRowColor]]theme.tableOddRowColor::
 +
-Background color for tables such as lists of open reviews for odd
-rows.  This is so you can have a different color for odd and even
-rows of the table.  The value must be a valid HTML hex color code,
-or standard color name.
+_(GWT UI only)_ Background color for tables such as lists of open reviews for
+odd rows.  This is so you can have a different color for odd and even rows of
+the table.  The value must be a valid HTML hex color code, or standard color
+name.
 +
 By default transparent.
 
 [[theme.tableEvenRowColor]]theme.tableEvenRowColor::
 +
-Background color for tables such as lists of open reviews for even
-rows.  This is so you can have a different color for odd and even
-rows of the table.  The value must be a valid HTML hex color code,
-or standard color name.
+_(GWT UI only)_ Background color for tables such as lists of open reviews for
+even rows.  This is so you can have a different color for odd and even rows of
+the table.  The value must be a valid HTML hex color code, or standard color
+name.
 +
 By default transparent.
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 1aa6cd7c..ff43520 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -374,6 +374,15 @@
 link:access-control.html#reference[here], but must not contain `${username}` or
 `${shardeduserid}`.
 
+[[label_ignoreSelfApproval]]
+=== `label.Label-Name.ignoreSelfApproval`
+
+If true, the label may be voted on by the uploader of the latest patch set,
+but their approval does not make a change submittable. Instead, a
+non-uploader who has the right to vote has to approve the change.
+
+Defaults to false.
+
 [[label_example]]
 === Example
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index ac303e9..cc5386f 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -144,9 +144,14 @@
 `gerrit.config` globally (link:config-gerrit.html#receive.maxObjectSizeLimit[
 receive.maxObjectSizeLimit]).
 +
-The project specific setting in `project.config` is only honored when it
-further reduces the global limit. The setting is not inherited from the
-parent project; it must be explicitly set per project.
+The project specific setting in `project.config` may not set a value higher
+than the global limit (if configured). In other words, it is only honored when
+it further reduces the global limit.
++
+When link:config-gerrit.html#receive.inheritProjectMaxObjectSizeLimit[
+`receive.inheritProjectmaxObjectSizeLimit`] is enabled in the global config,
+the value is inherited from the parent project. Otherwise, it is not inherited
+and must be explicitly set per project.
 +
 Default is zero.
 +
@@ -256,7 +261,8 @@
 
 - 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
 Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail.
+commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
+the initial commit on a branch.
 
 Merge strategy
 
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index d35772e..2153751 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -16,7 +16,8 @@
 with the database while Gerrit is offline, it's not easy to backup the data,
 and it's not possible to set up H2 in a load balanced/hotswap configuration.
 
-If this option interests you, you might want to consider link:install-quick.html[the quick guide].
+If this option interests you, you might want to consider
+link:linux-quickstart.html[the quick guide].
 
 [[createdb_derby]]
 === Apache Derby
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 979d021..6331581 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -148,6 +148,11 @@
 Note that when building an individual plugin, the `core.zip` package
 is not regenerated.
 
+To build with all Error Prone warnings activated, run:
+
+----
+  bazel build --java_toolchain //tools:error_prone_warnings_toolchain //...
+----
 
 
 [[IDEs]]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 8337c0b..bc9f782 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.5), and to format Bazel BUILD, WORKSPACE and .bzl files the
+tool (version 1.6), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
 tool (version 0.15.0).
 These tools automatically apply format according to the style guides; this
@@ -368,6 +368,36 @@
 that they are vetted long enough before they go into a release and we can be sure
 that the update doesn't introduce a regression.
 
+[[deprecating-features]]
+=== Deprecating features
+
+Gerrit should be as stable as possible and we aim to add only features that last.
+However, sometimes we are required to deprecate and remove features to be able
+to move forward with the project and keep the code-base clean. The following process
+should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
+is that we have a structured process for deprecation that users, administrators and
+developers can agree and rely on.
+
+General process:
+* Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
+  further development or improvement. If in doubt, consult the mailing list.
+* If you can provide a schema migration that moves users to a comparable feature, do
+  so and stop here.
+* Mark the feature as deprecated in the documentation and release notes.
+* If possible, mark the feature deprecated in any user-visible interface. For example,
+  if you are deprecating a Git push option, add a message to the Git response if
+  the user provided the option informing them about deprecation.
+* Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
+  Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
+  number that has to be branched off before removing the feature)
+* Gate the feature behind a config that is off by default (forcing admins to turn
+  the deprecated feature on explicitly).
+* After the next release was branched off, remove any code that backed the feature.
+
+You can optionally consult the mailing list to ask if there are users of the feature you
+wish to deprecate. If there are no major users, you can remove the feature without
+following this process and without the grace period of one release.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 6e39502..0f23db5 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -3,7 +3,7 @@
 This document is about configuring Gerrit Code Review into an
 Eclipse workspace for development and debugging with GWT.
 
-Java 6 or later SDK is also required to run GWT's compiler and
+Java 8 or later SDK is also required to run GWT's compiler and
 runtime debugging environment.
 
 
@@ -49,6 +49,19 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
+[[Newer Java versions]]
+
+Java 9 and later are supported, but some adjustments must be done, because
+Java 8 is still the default:
+
+* Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
+* Change execution environemnt for gerrit project to: JavaSE-9 (java-9-openjdk-9)
+* Check that compiler compliance level in gerrit project is set to: 9
+* Add this parameter to VM argument for gerrit_daemin launcher:
+----
+  --add-modules java.activation \
+  --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+----
 
 [[Formatting]]
 == Code Formatter Settings
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 5c008c7..6517262 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2534,7 +2534,7 @@
 attribute.
 
 Documentation may be written in the Markdown flavor
-link:https://github.com/sirthias/pegdown[pegdown]
+link:https://github.com/vsch/flexmark-java[flexmark-java]
 if the file name ends with `.md`. Gerrit will automatically convert
 Markdown to HTML if accessed with extension `.html`.
 
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
index 79049fc..5621d32 100644
--- a/Documentation/dev-polygerrit.txt
+++ b/Documentation/dev-polygerrit.txt
@@ -1,12 +1,15 @@
 = PolyGerrit - GUI
 
-[IMPORTANT]
-PolyGerrit is still a beta feature. Some features may be missing.
-
 == Configuring
 
 By default both GWT and PolyGerrit UI are available to users.
 
+To make PolyGerrit the default UI but keep GWT as a secondary UI:
+----
+[gerrit]
+        ui = POLYGERRIT
+----
+
 To disable GWT but not PolyGerrit:
 ----
 [gerrit]
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 2afac94..0abd8a1 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -365,6 +365,17 @@
 must reference the new version. Upload a change to bazlets repository with
 api version upgrade.
 
+[[clean-up-on-master]]
+=== Clean up on master
+
+Once you are done with the release, check if there are any code changes in the
+master branch that were gated on the next release. Mostly, these are
+feature-deprecations that we were holding off on to have a stable release where
+the feature is still contained, but marked as deprecated.
+
+See link:dev-contributing.html#deprecating-features[Deprecating features] for
+details.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/error-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index 9cddd85..08f2c09 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -1,4 +1,4 @@
-= missing Change-Id in commit message footer
+= commit xxxxxxx: missing Change-Id in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-missing-subject.txt b/Documentation/error-missing-subject.txt
index 3703ade..6ef37a4 100644
--- a/Documentation/error-missing-subject.txt
+++ b/Documentation/error-missing-subject.txt
@@ -1,4 +1,4 @@
-= missing subject; Change-Id must be in commit message footer
+= commit xxxxxxx: missing subject; Change-Id must be in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-multiple-changeid-lines.txt b/Documentation/error-multiple-changeid-lines.txt
index 0729547..31567f4 100644
--- a/Documentation/error-multiple-changeid-lines.txt
+++ b/Documentation/error-multiple-changeid-lines.txt
@@ -1,4 +1,4 @@
-= multiple Change-Id lines in commit message footer
+= commit xxxxxxx: multiple Change-Id lines in message footer
 
 With this error message Gerrit rejects to push a commit if the commit
 message footer of the pushed commit contains several Change-Id lines.
diff --git a/Documentation/images/inline-edit-add-file-page.png b/Documentation/images/inline-edit-add-file-page.png
new file mode 100644
index 0000000..1a761b4
--- /dev/null
+++ b/Documentation/images/inline-edit-add-file-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change-form.png b/Documentation/images/inline-edit-create-change-form.png
new file mode 100644
index 0000000..7a93460
--- /dev/null
+++ b/Documentation/images/inline-edit-create-change-form.png
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change.png b/Documentation/images/inline-edit-create-change.png
new file mode 100644
index 0000000..1df0421
--- /dev/null
+++ b/Documentation/images/inline-edit-create-change.png
Binary files differ
diff --git a/Documentation/images/inline-edit-delete-file.png b/Documentation/images/inline-edit-delete-file.png
new file mode 100644
index 0000000..1634e0f
--- /dev/null
+++ b/Documentation/images/inline-edit-delete-file.png
Binary files differ
diff --git a/Documentation/images/inline-edit-diff-screen.png b/Documentation/images/inline-edit-diff-screen.png
new file mode 100644
index 0000000..228484a
--- /dev/null
+++ b/Documentation/images/inline-edit-diff-screen.png
Binary files differ
diff --git a/Documentation/images/inline-edit-home-page.png b/Documentation/images/inline-edit-home-page.png
new file mode 100644
index 0000000..a1b8eb4
--- /dev/null
+++ b/Documentation/images/inline-edit-home-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-new-change-page.png b/Documentation/images/inline-edit-new-change-page.png
new file mode 100644
index 0000000..8a33dd6
--- /dev/null
+++ b/Documentation/images/inline-edit-new-change-page.png
Binary files differ
diff --git a/Documentation/images/inline-edit-open-file.png b/Documentation/images/inline-edit-open-file.png
new file mode 100644
index 0000000..a5422f5
--- /dev/null
+++ b/Documentation/images/inline-edit-open-file.png
Binary files differ
diff --git a/Documentation/images/inline-edit-prefill-files.png b/Documentation/images/inline-edit-prefill-files.png
new file mode 100644
index 0000000..0b2b766
--- /dev/null
+++ b/Documentation/images/inline-edit-prefill-files.png
Binary files differ
diff --git a/Documentation/images/inline-edit-review-message.png b/Documentation/images/inline-edit-review-message.png
new file mode 100644
index 0000000..bd76fad
--- /dev/null
+++ b/Documentation/images/inline-edit-review-message.png
Binary files differ
diff --git a/Documentation/images/inline-edit-start-review-button.png b/Documentation/images/inline-edit-start-review-button.png
new file mode 100644
index 0000000..df6350b
--- /dev/null
+++ b/Documentation/images/inline-edit-start-review-button.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 84925f4..6011158 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -67,6 +67,7 @@
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
+. link:user-request-tracing.html[Request Tracing]
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
deleted file mode 100644
index 2503449..0000000
--- a/Documentation/install-quick.txt
+++ /dev/null
@@ -1,234 +0,0 @@
-= Gerrit Code Review - Quick get started guide
-
-****
-This guide was made with the impatient in mind, ready to try out Gerrit on their
-own server but not prepared to make the full installation procedure yet.
-
-Explanation is sparse and you should not use a server installed this way in a
-live setup, this is made with proof of concept activities in mind.
-
-It is presumed you install it on a Unix based server such as any of the Linux
-flavors or BSD.
-
-It's also presumed that you have access to an OpenID enabled email address.
-Examples of OpenID enable email providers are Gmail, Yahoo! Mail and Hotmail.
-It's also possible to register a custom email address with OpenID, but that is
-outside the scope of this quick installation guide. For testing purposes one of
-the above providers should be fine. Please note that network access to the
-OpenID provider you choose is necessary for both you and your Gerrit instance.
-****
-
-
-[[requirements]]
-== Requirements
-
-Most distributions come with Java today. Do you already have Java installed?
-
-----
-  $ java -version
-  openjdk version "1.8.0_72"
-  OpenJDK Runtime Environment (build 1.8.0_72-b15)
-  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
-----
-
-If Java isn't installed, get it:
-
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
-
-
-[[user]]
-== Create a user to host the Gerrit service
-
-We will run the service as a non-privileged user on your system.
-First create the user and then become the user:
-
-----
-  $ sudo adduser gerrit
-  $ sudo su gerrit
-----
-
-If you don't have root privileges you could skip this step and run Gerrit
-as your own user as well.
-
-
-[[download]]
-== Download Gerrit
-
-It's time to download the archive that contains the Gerrit web and ssh service.
-
-You can choose from different versions to download from here:
-
-* https://www.gerritcodereview.com/download/index.html[A list of releases available]
-
-This tutorial is based on version 2.2.2, and you can download that from this link
-
-* https://www.gerritcodereview.com/download/gerrit-2.2.2.war[Link to the 2.2.2 war archive]
-
-
-[[initialization]]
-== Initialize the Site
-
-It's time to run the initialization, and with the batch switch enabled, we don't have to answer any questions at all:
-
-----
-  gerrit@host:~$ java -jar gerrit.war init --batch -d ~/gerrit_testsite
-  Generating SSH host key ... rsa(simple)... done
-  Initialized /home/gerrit/gerrit_testsite
-  Executing /home/gerrit/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-When the init is complete, you can review your settings in the
-file `'$site_path/etc/gerrit.config'`.
-
-Note that initialization also starts the server.  If any settings changes are
-made, the server must be restarted before they will take effect.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh restart
-  Stopping Gerrit Code Review: OK
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-The server can be also stopped and started by passing the `stop` and `start`
-commands to gerrit.sh.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh stop
-  Stopping Gerrit Code Review: OK
-  gerrit@host:~$
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-include::config-login-register.txt[]
-
-== Project creation
-
-Your base Gerrit server is now running and you have a user that's ready
-to interact with it.  You now have two options, either you create a new
-test project to work with or you already have a git with history that
-you would like to import into Gerrit and try out code review on.
-
-=== New project from scratch
-If you choose to create a new repository from scratch, it's easier for
-you to create a project with an initial commit in it. That way first
-time setup between client and server is easier.
-
-This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project --empty-commit
-  user@host:~$
-----
-
-This will create a repository that you can clone to work with.
-
-=== Already existing project
-
-The other alternative is if you already have a git project that you
-want to try out Gerrit on.
-First you have to create the project.  This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project
-  user@host:~$
-----
-
-You need to make sure that at least initially your account is granted
-"Create Reference" privileges for the refs/heads/* reference.
-This is done via the web interface in the Admin/Projects/Access page
-that correspond to your project.
-
-After that it's time to upload the previous history to the server:
-
-----
-  user@host:~/my-project$ git push ssh://user@localhost:29418/demo-project *:*
-  Counting objects: 2011, done.
-  Writing objects: 100% (2011/2011), 456293 bytes, done.
-  Total 2011 (delta 0), reused 0 (delta 0)
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      master -> master
-  user@host:~/my-project$
-----
-
-This will create a repository that you can clone to work with.
-
-
-== My first change
-
-Download a local clone of the repository and move into it
-
-----
-  user@host:~$ git clone ssh://user@localhost:29418/demo-project
-  Cloning into demo-project...
-  remote: Counting objects: 2, done
-  remote: Finding sources: 100% (2/2)
-  remote: Total 2 (delta 0), reused 0 (delta 0)
-  user@host:~$ cd demo-project
-  user@host:~/demo-project$
-----
-
-Install the link:user-changeid.html[Change-Id commitmsg hook]
-
-----
-  scp -p -P 29418 user@localhost:hooks/commit-msg $(git rev-parse --git-dir)/hooks/
-----
-
-Then make a change to the repository and upload it as a reviewable change
-in Gerrit.
-
-----
-  user@host:~/demo-project$ date > testfile.txt
-  user@host:~/demo-project$ git add testfile.txt
-  user@host:~/demo-project$ git commit -m "My pretty test commit"
-  [master ff643a5] My pretty test commit
-   1 files changed, 1 insertions(+), 0 deletions(-)
-   create mode 100644 testfile.txt
-  user@host:~/demo-project$
-----
-
-Usually when you push to a remote git, you push to the reference
-`'/refs/heads/branch'`, but when working with Gerrit you have to push to a
-virtual branch representing "code review before submission to branch".
-This virtual name space is known as /refs/for/<branch>
-
-----
-  user@host:~/demo-project$ git push origin HEAD:refs/for/master
-  Counting objects: 4, done.
-  Writing objects: 100% (3/3), 293 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  remote:
-  remote: New Changes:
-  remote:   http://localhost:8080/1
-  remote:
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      HEAD -> refs/for/master
-  user@host:~/demo-project$
-----
-
-You should now be able to access your change by browsing to the http URL
-suggested above, http://localhost:8080/1
-
-
-== Quick Installation Complete
-
-This covers the scope of getting Gerrit started and your first change uploaded.
-It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
-learn more about the workflow of Gerrit.
-
-To read more on the installation of Gerrit please see link:install.html[the detailed
-installation page].
-
-
-GERRIT
-------
-
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index cc19b3f..dbca368 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,9 @@
 
 To run the Gerrit service, the following requirement must be met on the host:
 
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
++
+Gerrit is not yet compatible with Java 9 or newer at this time.
 
 By default, Gerrit uses link:note-db.html[NoteDB] as the storage backend. (If
 desired, you can _optionally_ use an external database such as MySQL or
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 2464c3a..bfebc6a 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -18,7 +18,8 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment 1.8 (or higher).
+. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
+    9 or newer yet.
 
 == Download Gerrit
 
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 65a15ca..c2a7d21 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -263,6 +263,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true,
       "groups": {
          "53a4f647a89ea57992571187d8025f830625192a": {
@@ -313,6 +314,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true
     }
   }
@@ -399,6 +401,8 @@
 Whether the calling user can upload to any ref.
 |`can_add`            |not set if `false`|
 Whether the calling user can add any ref.
+|`can_add_tags`       |not set if `false`|
+Whether the calling user can add any tag ref.
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index edb642e..e28a9c4 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -418,7 +418,10 @@
 .Response
 ----
   HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
 
+  )]}'
   ok
 ----
 
@@ -1095,7 +1098,10 @@
 .Response
 ----
   HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
 
+  )]}'
   ok
 ----
 
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 4b8922a..3ec989e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1927,7 +1927,7 @@
 GerritInfo] entity.
 |`note_db_enabled`         |not set if `false`|
 Whether the NoteDb storage backend is fully enabled.
-|`plugin `                 ||
+|`plugin`                  ||
 Information about Gerrit extensions by plugins as
 link:#plugin-config-info[PluginConfigInfo] entity.
 |`receive`                 |optional|
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 5fd8be4..b517d3c 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1141,6 +1141,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
@@ -1242,6 +1243,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true,
     "groups": {
       "global:Anonymous-Users": {
@@ -1410,6 +1412,95 @@
   Content-Disposition: attachment
 ----
 
+[[check]]
+=== Check project consistency
+
+Performs consistency checks on the project.
+
+Which consistency checks should be performed is controlled by the
+link:#check-project-input[CheckProjectInput] entity in the request
+body.
+
+The following consistency checks are supported:
+
+[[auto-closeable-changes-check]]
+--
+* AutoCloseableChangesCheck: Searches for open changes that can be
+  auto-closed because a patch set of the change is already contained in
+  the destination branch or because the destination branch contains a
+  commit with the same Change-Id. Normally Gerrit auto-closes such
+  changes when the corresponding commits are pushed directly to the
+  repository. However if a branch is updated behind Gerrit's back or if
+  auto-closing changes fails (and the push is still successful) change
+  states can get inconsistent (changes that are already part of the
+  destination branch are still open). This consistency check is
+  intended to detect and repair this situation.
+--
+
+To fix any problems that can be fixed automatically set the `fix` field
+in the inputs for the consistency checks  to `true`.
+
+This REST endpoint requires the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability.
+
+.Request
+----
+  POST /projects/MyProject/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "auto_closeable_changes_check": {
+      "fix": true,
+      "branch": "refs/heads/master",
+      "max_commits": 100
+    }
+  }
+----
+
+As response a link:#check-project-result-info[CheckProjectResultInfo]
+entity is returned that results for the consistency checks.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "auto_closeable_changes_check_result": {
+      "auto_closeable_changes": {
+        "refs/heads/master": [
+          {
+            "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "project": "myProject",
+            "branch": "master",
+            "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "subject": "Implementing Feature X",
+            "status": "NEW",
+            "created": "2013-02-01 09:59:32.126000000",
+            "updated": "2013-02-21 11:16:36.775000000",
+            "insertions": 34,
+            "deletions": 101,
+            "_number": 3965,
+            "owner": {
+              "name": "John Doe"
+            },
+            "problems": [
+              {
+                "message": "Patch set 1 (2f15e416237ed9b561199f24184f5f5d2708c584) is merged into destination ref refs/heads/master (2f15e416237ed9b561199f24184f5f5d2708c584), but change status is NEW",
+                "status": "FIXED",
+                "outcome": "Marked change as merged"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2850,6 +2941,52 @@
 check. This defaults to `read`. If given, it `ref` must be given too.
 |=========================================
 
+[[auto_closeable_changes_check_input]]
+=== AutoCloseableChangesCheckInput
+The `AutoCloseableChangesCheckInput` entity contains options for running
+the link:#auto-closeable-changes-check[AutoCloseableChangesCheck].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`fix`           |optional|
+Whether auto-closeable changes should be closed automatically.
+|`branch`        ||
+The branch for which the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] should be performed. The 'refs/heads/'
+prefix for the branch name can be omitted.
+|`skip_commits`  |optional|
+Number of commits that should be skipped when walking the commits of
+the branch.
+|`max_commits`   |optional|
+Maximum number of commits to walk. If not specified this defaults to
+10,000 commits. 10,000 is also the maximum that can be set.
+Auto-closing changes is an expensive operation and the more commits
+are walked the slower it gets. This is why you should avoid walking too
+many commits.
+|=============================
+
+[[auto_closeable_changes_check_result]]
+=== AutoCloseableChangesCheckResult
+The `AutoCloseableChangesCheckResult` entity contains the results of
+running the link:#auto-closeable-changes-check[AutoCloseableChangesCheck]
+on a project.
+
+[options="header",cols="1,6"]
+|====================================
+|Field Name              |Description
+|`auto_closeable_changes`|
+Changes that can be auto-closed as list of
+link:rest-api-changes.html#change-info[ChangeInfo] entities. For each
+returned link:rest-api-changes.html#change-info[ChangeInfo] entity the
+`problems` field is populated that includes details about the detected
+issues. If `fix` in the link:#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] was set to `true`, `status` and
+`outcome` in link:rest-api-changes.html#problem-info[ProblemInfo] are
+populated. If the status says `FIXED` Gerrit was able to auto-close the
+change now.
+|====================================
+
 [[ban-input]]
 === BanInput
 The `BanInput` entity contains information for banning commits in a
@@ -2907,6 +3044,36 @@
 If not set, `HEAD` will be used as base revision.
 |=======================
 
+[[check-project-input]]
+=== CheckProjectInput
+The `CheckProjectInput` entity contains information about which
+consistency checks should be run on a project.
+
+[options="header",cols="1,^2,4"]
+|===========================================
+|Field Name                    ||Description
+|`auto_closeable_changes_check`|optional|
+Parameters for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] entity.
+|===========================================
+
+[[check-project-result-info]]
+=== CheckProjectResultInfo
+The `CheckProjectResultInfo` entity contains results for consistency
+checks that have been run on a project.
+
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name                           ||Description
+|`auto_closeable_changes_check_result`|optional|
+Results for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_result[
+AutoCloseableChangesCheckResult] entity.
+|==================================================
+
 [[config-info]]
 === ConfigInfo
 The `ConfigInfo` entity contains information about the effective project
@@ -3270,16 +3437,17 @@
 |===============================
 |Field Name        ||Description
 |`value`           |optional|
-The effective value of the max object size limit as a formatted string. +
+The effective value in bytes of the max object size limit. +
 Not set if there is no limit for the object size.
 |`configured_value`|optional|
 The max object size limit that is configured on the project as a
 formatted string. +
 Not set if there is no limit for the object size configured on project
 level.
-|`inherited_value` |optional|
-The max object size limit that is inherited as a formatted string. +
-Not set if there is no global limit for the object size.
+|`summary`         |optional|
+A string describing whether the value was inherited or overridden from
+the parent project or global config. +
+Not set if not inherited or overridden.
 |===============================
 
 [[project-access-input]]
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 0957d32..8f6a47b 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -191,6 +191,53 @@
 "`422 Unprocessable Entity`" is returned if the ID of a resource that is
 specified in the request body cannot be resolved.
 
+[[tracing]]
+=== Request Tracing
+For each REST endpoint tracing can be enabled by setting the
+`trace=<trace-id>` request parameter. It is recommended to use the ID
+of the issue that is being investigated as trace ID.
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?trace=issue/123&q=J
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?trace&q=J
+----
+
+Alternatively request tracing can also be enabled by setting the
+`X-Gerrit-Trace` header:
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?q=J
+  X-Gerrit-Trace: issue/123
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. The trace ID is returned with
+the REST response in the `X-Gerrit-Trace` header.
+
+.Example Response
+----
+HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+  X-Gerrit-Trace: 1533885943749-8257c498
+
+  )]}'
+  ... <json> ...
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index bce8183..ada2560 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -1,191 +1,236 @@
-= Inline Edit
+= Creating and Editing Changes in the Gerrit Web Interface
 
-This page explains the workflow for creating and amending changes in the
-browser.
+== Overview
+
+The following content explains how to use the Gerrit web interface to create
+and edit changes. Use the web interface to make minor changes to files. When
+you create a change in the Gerrit user interface, you don't clone a Gerrit
+repository or use the CLI to issue Git commands — you perform your work
+directly in the Gerrit web interface.
+
+To learn more, see the link:intro-user.html[Gerrit User's Guide].
 
 
 [[create-change]]
-== Creating a New Change
+== Creating a Change
 
-A new change can be created directly in the browser, meaning it is not necessary
-to clone the whole repository to make trivial changes.
+To create a change in the Gerrit web interface:
 
-The new change is created as a public
-link:user-upload.html#wip[work-in-progress change].
+. From the link:http://gerrit-review.googlesource.com[Gerrit Code Review]
+  dashboard, select Browse > Repositories.
 
-There are two different ways to create a new change:
+. Under Repository Name, click the name of the repository you want to work
+  on. For example, Public-Projects. To find a specific repository, enter all
+  or part of its name next to Filter:
++
+image::images/inline-edit-home-page.png[width=600]
 
-By clicking on the 'Create Change' button in the project screen:
+. In the left navigation panel for the repository you selected, click
+  Commands:
++
+image::images/inline-edit-create-change.png[width=350]
 
-[[create-change-from-project-info-screen]]
+. Under Repository Commands, click Create Change.
 
-image::images/inline-edit-create-change-project-screen.png[width=800, link="images/inline-edit-create-change-project-screen.png"]
+. In the Create Change window, enter the following information:
 
-The user can select the branch on which the new change should be created:
+   *  Select branch for new change: Specify the destination branch of the
+      change.
 
-image::images/inline-edit-create-change-project-screen-dialog.png[width=800, link="images/inline-edit-create-change-project-screen-dialog.png"]
+   *  Provide base commit SHA1 for change: Leave this field blank.
 
-By clicking the 'Follow-Up' button on the change screen, to create a new change
-based on the selected change.
++
+IMPORTANT: Git uses a unique SHA1 value to identify each and every commit (in
+other words, each Git commit generates a new SHA1 hash). This value differs
+from a Gerrit Change-Id, which is used by Gerrit to uniquely identify a
+change. The Gerrit Change-Id remains static throughout the life of a Gerrit
+change.
 
-[[create-change-from-change-screen]]
+   -  Description: Briefly describe the change. Be sure to use the
+      link:dev-contributing.html#commit-message[Commit Message] format.
+      The first line becomes the subject of the change and is included in
+      the Commit Message. Because the message also appears on its own in
+      dashboards and in the results of `git log --pretty=oneline output`,
+      make the message informative and brief.
 
-image::images/inline-edit-create-follow-up-change.png[width=800, link="images/inline-edit-create-follow-up-change.png"]
+   -  Private change: Select this option to designate this change as private.
+      Only you (and any reviewers you add) can see your private changes.
+
+. On the Create Change window, click Create. Gerrit creates a public Work
+  In Progress (WIP) change. Until the change is sent for review, it remains a
+  WIP and appears in _your_ dashboard only. In addition, all email
+  notifications are turned off.
+
+. Add the files you want to be reviewed.
+
+
+[[add-files]]
+== Adding a File to a Change
+
+Files can only be added to changes that have not been merged into the code
+base.
+
+To add a file to the change:
+
+. In the top left corner of the change, click Edit.
+. Next to Files, click Open:
+
++
+image::images/inline-edit-open-file.png[width=600]
+
+. In the Open File window, do one of the following:
+
+* To add an existing file:
+
+ ** Enter all or part of the file name in the text box. Gerrit automatically
+    populates a list of possible matching files:
++
+image::images/inline-edit-prefill-files.png[width=500]
++
+ ** Select the file you want to add to the change.
+ ** Click Open.
++
+_or,_
+
+*  To create a new file, enter the name of the new file you want to add to the
+change and then click Open.
+
 
 [[editing-change]]
-== Editing Changes
+== Modifying a Change
 
-To switch to edit mode, press the 'Edit' button at the top of the file list:
+To work on a file you've added to a change:
 
-[[switch-to-edit-mode]]
-image::images/inline-edit-enter-edit-mode-from-file-list.png[width=800, link="images/inline-edit-enter-edit-mode-from-file-list.png"]
+. On the change page, click the file name. When you add a new file to a
+  change, a blank page is displayed. When you add an existing file to a
+  change, the entire file is displayed.
 
-While in edit mode, it is possible to add new files to the change by clicking
-the 'Add...' button at the top of the file list.
+. Update the file and then click Save. You _must_ click Save to add the
+  file to the change.
 
-File changes can be reverted or files can be removed from the change or
-deleted files can be restored, by clicking the icons to the left of the file
-name.
+. To close the text editor and display the change page, click Close.
++
+When you save your work and close the file, the file is added to the change
+and the file name is listed in the Files section. The letter displayed to the
+left of the file name denotes the action performed on the file. In this case,
+one file was modified:
 
-To switch from edit mode back to review mode, click the 'Done Editing' button.
+-  M: Modified
+-  A: Added
+-  D: Deleted
++
+image::images/inline-edit-add-file-page.png[width=650]
 
-image::images/inline-edit-file-list-in-edit-mode.png[width=800, link="images/inline-edit-file-list-in-edit-mode.png"]
+. When you're done editing and adding files, click Stop Editing.
 
-[[open-full-screen-editor]]
-While in edit mode, clicking on a file name in the file list opens a full
-screen editor for that file.
+. Click Publish Edit. When you publish an edit, you promote it to a regular
+  patch set. The special ref that represents the change is deleted when the
+  change is published.
 
-To save edits, click the 'Save' button or press `CTRL-S`.  To return to the
-change screen, click the 'Close' button.
+Not happy with your edits? Click Delete Edit.
 
-Note that when editing the commit message, trailing blank lines will be stripped.
 
-image::images/inline-edit-full-screen-editor.png[width=800, link="images/inline-edit-full-screen-editor.png"]
+[[submit-change]]
+== Starting the Review
 
-If there are unsaved edits when the 'Close' button is pressed, a dialog will
-pop up asking to confirm the edits.
+When you start a review, Gerrit removes the WIP designation and submits
+the change to code review. The change appears in other Gerrit dashboards and
+reviewers are notified when the change is updated.
 
-image::images/inline-edit-confirm-unsaved-edits.png[width=800, link="images/inline-edit-confirm-unsaved-edits.png"]
+To start a review:
 
-To discard the unsaved edits and return to the change screen, click the 'OK'
-button. To continue editing, click 'Cancel'.
+. Open the change and then click Start Review:
++
+image::images/inline-edit-start-review-button.png[width=400]
 
-[[switch-to-edit-mode-from-side-by-side]]
+. In the change notification form:
 
-While in review mode, it is possible to switch directly to edit mode and into an
-editor for a file under review by clicking on the edit icon in the patch set list
-on the side-by-side diff view.
+ ** Add the names of the reviewers and anyone else you want to copy.
+ ** Describe the change.
+ ** Click Start Review:
++
+image::images/inline-edit-review-message.png[width=550]
 
-image::images/inline-edit-enter-edit-mode-from-diff.png[width=800, link="images/inline-edit-enter-edit-mode-from-diff.png"]
+The change is now displayed in other Gerrit dashboards and reviewers are
+notified that the change is available for code review.
 
-[[reviewing-changes-made-in-change-edit]]
-== Reviewing Change Edits
 
-Change edits are reviewed in the same way as regular patch sets, using the
-side-by-side diff screen. Change edits are shown as 'edit' in the patch list
-on the diff screen:
+[[review-edits]]
+== Reviewing Changes
 
-image::images/inline-edit-edit-in-diff-screen-patch-list.png[width=800, link="images/inline-edit-edit-in-diff-screen-patch-list.png"]
+Use the side-by-side diff screen.
 
-and on the change screen:
+image::images/inline-edit-diff-screen.png[width=800]
 
-image::images/inline-edit-edit-in-patch-list.png[width=800, link="images/inline-edit-edit-in-patch-list.png"]
+It's possible that subsequent patch sets may exist. For example, this sequence
+means that the change was created on top of patch set 9 while a regular
+patchset was uploaded later:
 
-Note that patch sets may exist that were created after the change edit was created.
+1 2 3 4 5 6 7 8 9 edit 10
 
-For example this sequence:
 
-`1 2 3 4 5 6 7 8 9 edit 10`
+[[search-for-changes]]
+== Searching for Changes with Pending Edits
 
-means that the change edit was created on top of patch set number 9 and a regular
-patch set was uploaded later.
+To find changes with pending edits:
 
-[[change-edit-actions]]
-== Change Edit Actions
+*  From the Gerrit dashboard, select Your > Changes. All your changes are
+listed, according to Work in progress, Outgoing reviews, Incoming reviews,
+CCed on, and Recently closed.
 
-Change edits can be deleted, published and rebased, and a patch set that
-represents a change edit can be downloaded like a regular patch set.
+For more information about Search operators, see
+link:user-search.html[Searching Changes]. For example, to find only
+those changes that contain edits, see link:user-search.html#has[has:edit].
 
-[[delete-change-edit]]
 
-There is a special ref for a change edit. When the change edit is deleted, this
-ref is deleted as well. To delete a change edit click on the "Delete Edit"
-button.
+[change-edit-actions]
+== Modifying Changes
 
-[[publish-change-edit]]
-
-When a change edit is based on the current patch set, it can be published. By
-publishing a change edit it is promoted to a regular patch set. The special ref
-that represents the change edit is deleted on publish. To publish a change edit
-click on the "Publish Edit" button. This button is only shown when the change
-edit is based on the current patch set. Otherwise the change edit must first be
-rebased onto the current patch set.
 
 [[rebase-change-edit]]
+=== Rebasing a Change Edit
 
-Only change edits that are based on the current patch set can be published. If
-in the meantime a new patch set was uploaded, the change edit must be rebased on
-top of the current patch set before it can be published. Rebasing a change
-edit is done by clicking on the "Rebase Edit" button. If the rebase results in
-conflicts, these conflicts cannot be resolved in the browser. In this case the
-change edit must be downloaded (see below) and the conflicts must be resolved in
-the local environment. The commit that contains the conflict resolution can then
-be uploaded by setting `edit` as option on the target ref:
+Only when a change is based on the current patch set can the change be
+published. In the meantime, if a new patch set has been uploaded, the change
+must be rebased on top of the current patch set before the change can be
+published.
 
-----
-  $ git push host HEAD:refs/for/master%edit
-----
+To rebase a change:
+
+-  Open the change and then click Rebase Edit.
+
+If the rebase generates conflicts, the conflicts can't be resolved in the web
+interface. Instead, the change must be downloaded (see below) and the conflicts
+resolved in the local environment.
+
+When the conflicts are resolved in the local environment, the commit that
+contains the conflict resolution can be uploaded by setting `edit` as an
+option on the target ref. For example:
+
+....
+$ git push host HEAD:refs/for/master%edit
+....
+
 
 [[download-change-edit-patch]]
+=== Downloading a Patch
 
-Like regular patch sets, change edits can be downloaded by the download
-commands (e.g. provided by the `download-commands` plugin). To download a
-change edit, select the desired scheme from the "Download" dropdown and copy the
-command to your terminal. Note: only change edit owners and users that were
-granted the link:access-control.html#capability_accessDatabase[accessDatabase]
-global capability are able to access change edit refs.
+As with regular patch sets, you can download changes. For example, as provided
+by the `download-commands` plugin. Only the owners of a change and those
+users granted the
+link:access-control.html#capability_accessDatabase[accessDatabase] global
+capability can access change refs.
 
-[[search-for-change-edits]]
+To download a change:
 
-To search change edits from the UI the link:user-search.html#has[has:edit]
-predicate can be used.
+. Open the change, click the More icon, and then select Download patch.
+. Copy the desired scheme from the Download drop-down.
+. Paste the command into a terminal window.
 
-Alternatively change edits can be accessed through "My => Edits" dashboard.
-
-[[not-implemented-features]]
-== Not Implemented Features
-
-* Support default configuration options for inline editor that an
-administrator has set in `refs/users/default:preferences.config` file.
-
-* Allow to rename files that are already contained in the change (from the file table).
-The same rename file dialog can be used with preselected and disabled original file
-name.
-
-* Changed files in change edit should be marked as changed in file table in edit mode.
-One option is to use dirty icon or "*" char in front of changed files, another option
-is to use different hyperlink color for changed files (red?), to avoid adding yet another
-column to the file table
-
-* Add navigation icons in header area of edit screen. When dozen files need to be changed
-in context of change edit, this is not the best workflow to open one file in edit screen,
-change it, save it, close edit screen and select next file from the file table to edit.
-"<-" | "->" icons in header of edit screen could be used to navigate to the next file to
-change from the file table. This would behave like the navigation icons in side by side
-with the following logic on click:
-
-** "save-when-file-was-changed" or
-** "close-when-no-changes"
-
-* Implement conflict resolution during rebase of change edit using inline edit
-feature by creating new edit on top of current patch set with auto merge content
-
-* Similarly, reuse inline edit feature for conflict resolution during rebase of regular
-patch sets
+image::images/inline-edit-actions-download.png[width=600]
 
 GERRIT
-------
+
 Part of link:index.html[Gerrit Code Review]
 
-SEARCHBOX
----------
+SEARCHBOX
\ No newline at end of file
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
new file mode 100644
index 0000000..8430e97
--- /dev/null
+++ b/Documentation/user-request-tracing.txt
@@ -0,0 +1,72 @@
+= Request Tracing
+
+Gerrit supports on-demand tracing of single requests that results in
+additional logs with debug information that are written to the
+`error_log`. The logs that correspond to a traced request are
+associated with a unique trace ID. This trace ID is returned with the
+response and can be used by an administrator to find the matching log
+entries.
+
+How tracing is enabled and how the trace ID is returned depends on the
+request type:
+
+* REST API: For REST calls tracing can be enabled by setting the
+  `trace` request parameter or the `X-Gerrit-Trace` header, the trace
+  ID is returned as `X-Gerrit-Trace` header. More information about
+  this can be found in the link:rest-api.html#tracing[Request Tracing]
+  section of the link:rest-api.html[REST API documentation].
+* SSH API: For SSH calls tracing can be enabled by setting the
+  `--trace` option. More information about this can be found in
+  the link:cmd-index.html#trace[Trace] section of the
+  link:cmd-index.html[SSH command documentation].
+* Git: For Git pushes tracing can be enabled by setting the
+  `trace` push option, the trace ID is returned in the command output.
+  More information about this can be found in
+  the link:user-upload.html#trace[Trace] section of the
+  link:user-upload.html[upload documentation]. Tracing for Git requests
+  other than Git push is not supported.
+
+When request tracing is enabled it is possible to provide an ID that
+should be used as trace ID. If a trace ID is not provided a trace ID is
+automatically generated. The trace ID must be provided to the support
+team so that they can find the trace.
+
+When doing traces it is recommended to specify the ID of the issue
+that is being investigated as trace ID so that the traces of the issue
+can be found more easily. When the issue ID is used as trace ID there
+is no need to find the generated trace ID and report it in the issue.
+
+Since tracing consumes additional server resources tracing should only
+be enabled for single requests if there is a concrete need for
+debugging. In particular bots should never enable tracing for all their
+requests by default.
+
+== Find log entries for a trace ID
+
+If tracing is enabled all log messages that correspond to the traced
+request have a `TRACE_ID` tag set, e.g.:
+
+----
+[2018-08-13 15:28:08,913] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Received REST request: GET /a/accounts/self (parameters: [trace]) [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,914] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : Calling user: admin [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+[2018-08-13 15:28:08,942] [HTTP-76] TRACE com.google.gerrit.httpd.restapi.RestApiServlet : REST call succeeded: 200 [CONTEXT forced=true TRACE_ID="1534166888910-3985dfba" ]
+----
+
+By doing a grep with the trace ID over the error log the log entries
+that correspond to the request can be found.
+
+== Which information is captured in a trace?
+
+* request details
+** REST API: request URL, request parameter names, calling user,
+   response code, response body on errors
+** SSH API: parameter names
+** Git API: push options, magic branch parameter names
+* cache misses, cache evictions
+* reads from NoteDb, writes to NoteDb
+* reads of meta data files, writes of meta data files
+* index queries (with parameters and matches)
+* reindex events
+* permission checks (e.g. which rule is responsible for a deny)
+* timer metrics
+* all other logs
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index baf388e..751e886 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -421,6 +421,36 @@
   $ git push exp
 ----
 
+[[trace]]
+==== Trace
+
+When pushing to Gerrit tracing can be enabled by setting the
+`trace=<trace-id>` push option. It is recommended to use the ID of the
+issue that is being investigated as trace ID.
+
+----
+  git push -o trace=issue/123 ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master
+----
+
+It is also possible to omit the trace ID and get a unique trace ID
+generated.
+
+.Example Request
+----
+  git push -o trace ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master
+----
+
+Enabling tracing results in additional logs with debug information that
+are written to the `error_log`. All logs that correspond to the traced
+request are associated with the trace ID. This trace ID is returned in
+the command output:
+
+----
+  remote: TRACE_ID: 1534174322774-7edf2a7b
+----
+
+Given the trace ID an administrator can find the corresponding logs and
+investigate issues more easily.
 
 [[push_replace]]
 === Replace Changes
diff --git a/WORKSPACE b/WORKSPACE
index c1d7213..dca68d3 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -16,7 +16,7 @@
     name = "io_bazel_rules_closure",
     sha256 = "4dd84dd2bdd6c9f56cb5a475d504ea31d199c34309e202e9379501d01c3067e5",
     strip_prefix = "rules_closure-3103a773820b59b76345f94c231cb213e0d404e2",
-    url = "https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -174,12 +174,12 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
-FLOGGER_VERS = "0.2"
+FLOGGER_VERS = "0.3.1"
 
 maven_jar(
     name = "flogger",
     artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-    sha1 = "a22d04ed3b84bae8ecf8aa6d4430ad000bcdf7b4",
+    sha1 = "585030fe1ec709760cbef997a459729fb965df0e",
 )
 
 maven_jar(
@@ -191,7 +191,7 @@
 maven_jar(
     name = "flogger-system-backend",
     artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-    sha1 = "b995c84b8443d6cfbd011a55719b63494b974c3a",
+    sha1 = "287b569d76abcd82f9de87fe41829fbc7ebd8ac9",
 )
 
 maven_jar(
@@ -203,8 +203,8 @@
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.4",
-    sha1 = "d0de1ca9b69e69d1d497ee3c6009d015f64dad57",
+    artifact = "com.google.code.gson:gson:2.8.5",
+    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
 maven_jar(
@@ -351,22 +351,163 @@
     sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
 )
 
+FLEXMARK_VERS = "0.34.18"
+
 maven_jar(
-    name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.6.0",
-    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
+    name = "flexmark",
+    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
+    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
 )
 
 maven_jar(
-    name = "grappa",
-    artifact = "com.github.parboiled1:grappa:1.0.4",
-    sha1 = "ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5",
+    name = "flexmark-ext-abbreviation",
+    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
+    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
 )
 
 maven_jar(
-    name = "jitescript",
-    artifact = "me.qmx.jitescript:jitescript:0.4.0",
-    sha1 = "2e35862b0435c1b027a21f3d6eecbe50e6e08d54",
+    name = "flexmark-ext-anchorlink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
+    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+)
+
+maven_jar(
+    name = "flexmark-ext-autolink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
+    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+)
+
+maven_jar(
+    name = "flexmark-ext-definition",
+    artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
+    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+)
+
+maven_jar(
+    name = "flexmark-ext-emoji",
+    artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
+    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+)
+
+maven_jar(
+    name = "flexmark-ext-escaped-character",
+    artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
+    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+)
+
+maven_jar(
+    name = "flexmark-ext-footnotes",
+    artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
+    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-issues",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
+    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-strikethrough",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
+    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-tables",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
+    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-tasklist",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
+    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+)
+
+maven_jar(
+    name = "flexmark-ext-gfm-users",
+    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
+    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+)
+
+maven_jar(
+    name = "flexmark-ext-ins",
+    artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
+    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+)
+
+maven_jar(
+    name = "flexmark-ext-jekyll-front-matter",
+    artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
+    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+)
+
+maven_jar(
+    name = "flexmark-ext-superscript",
+    artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
+    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+)
+
+maven_jar(
+    name = "flexmark-ext-tables",
+    artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
+    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+)
+
+maven_jar(
+    name = "flexmark-ext-toc",
+    artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
+    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+)
+
+maven_jar(
+    name = "flexmark-ext-typographic",
+    artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
+    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+)
+
+maven_jar(
+    name = "flexmark-ext-wikilink",
+    artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
+    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+)
+
+maven_jar(
+    name = "flexmark-ext-yaml-front-matter",
+    artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
+    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+)
+
+maven_jar(
+    name = "flexmark-formatter",
+    artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
+    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+)
+
+maven_jar(
+    name = "flexmark-html-parser",
+    artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
+    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+)
+
+maven_jar(
+    name = "flexmark-profile-pegdown",
+    artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
+    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+)
+
+maven_jar(
+    name = "flexmark-util",
+    artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
+    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+)
+
+# Transitive dependency of flexmark
+maven_jar(
+    name = "autolink",
+    artifact = "org.nibor.autolink:autolink:0.7.0",
+    sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
 )
 
 GREENMAIL_VERS = "1.5.5"
@@ -405,36 +546,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "6.0"
+OW2_VERS = "6.2.1"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "bc6fa6b19424bb9592fe43bbc20178f92d403105",
+    sha1 = "c01b6798f81b0fc2c5faa70cbe468c275d4b50c7",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "dd1cc1381a970800268160203aae2d3784da779b",
+    sha1 = "e8b876c5ccf226cae2f44ed2c436ad3407d0ec1d",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "f256fd215d8dd5a4fa2ab3201bf653de266ed4ec",
+    sha1 = "eaf31376d741a3e2017248a4c759209fe25c77d3",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "a624f1a6e4e428dcd680a01bab2d4c56b35b18f0",
+    sha1 = "332b022092ecec53cdb6272dc436884b2d940615",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "430b2fc839b5de1f3643b528853d5cf26096c1de",
+    sha1 = "400d664d7c92a659d988c00cb65150d1b30cf339",
 )
 
 AUTO_VALUE_VERSION = "1.6.2"
@@ -675,13 +816,6 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-# Only needed when jgit is built from the development tree
-maven_jar(
-    name = "hamcrest-library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
 TRUTH_VERS = "0.42"
 
 maven_jar(
@@ -723,8 +857,8 @@
 
 maven_jar(
     name = "cglib-3_2",
-    artifact = "cglib:cglib-nodep:3.2.0",
-    sha1 = "cf1ca207c15b04ace918270b6cb3f5601160cdfd",
+    artifact = "cglib:cglib-nodep:3.2.6",
+    sha1 = "92bf48723d277d6efd1150b2f7e9e1e92cb56caf",
 )
 
 maven_jar(
@@ -861,8 +995,8 @@
 
 maven_jar(
     name = "postgresql",
-    artifact = "org.postgresql:postgresql:9.4.1211",
-    sha1 = "721e3017fab68db9f0b08537ec91b8d757973ca8",
+    artifact = "org.postgresql:postgresql:42.2.4",
+    sha1 = "dff98730c28a4b3a3263f0cf4abb9a3392f815a7",
 )
 
 maven_jar(
@@ -904,8 +1038,8 @@
 
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.3.2",
-    sha1 = "2077ea5f00fdd2d6af85223b730ba8047303297f",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.4.0",
+    sha1 = "0eaa13decb9796eb671c5841d0770ae68b348da5",
 )
 
 JACKSON_VERSION = "2.8.9"
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 6d7102a..fc96715 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -15,3 +15,17 @@
     ],
     visibility = ["//visibility:public"],
 )
+
+java_library(
+    name = "query_parser",
+    srcs = [":query"],
+    visibility = [
+        "//java/com/google/gerrit/index:__pkg__",
+        "//javatests/com/google/gerrit/index:__pkg__",
+        "//plugins:__pkg__",
+    ],
+    deps = [
+        "//java/com/google/gerrit/index:query_exception",
+        "//lib/antlr:java-runtime",
+    ],
+)
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 3501b8b..2e01131 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -103,6 +103,9 @@
                       default=None,
                       action='store',
                       help='only abandon changes owned by the given user')
+    parser.add_option('--exclude-wip', dest='exclude_wip',
+                      action='store_true',
+                      help='Exclude changes that are Work-in-Progress')
     parser.add_option('-v', '--verbose', dest='verbose',
                       action='store_true',
                       help='enable verbose (debug) logging')
@@ -148,7 +151,9 @@
         if options.testmode:
             query_terms = ["status:new", "owner:self", "topic:test-abandon"]
         else:
-            query_terms = ["status:new", "-is:wip", "age:%s" % options.age]
+            query_terms = ["status:new", "age:%s" % options.age]
+        if options.exclude_wip:
+            query_terms += ["-is:wip"]
         if options.branches:
             query_terms += ["branch:%s" % b for b in options.branches]
         elif options.exclude_branches:
diff --git a/gerrit-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
deleted file mode 100644
index c1e34dd..0000000
--- a/gerrit-acceptance-tests/tests.bzl
+++ /dev/null
@@ -1,21 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-def acceptance_tests(
-        group,
-        deps = [],
-        labels = [],
-        vm_args = ["-Xmx256m"],
-        **kwargs):
-    junit_tests(
-        name = group,
-        deps = deps + [
-            "//gerrit-acceptance-tests:lib",
-        ],
-        tags = labels + [
-            "acceptance",
-            "slow",
-        ],
-        size = "large",
-        jvm_flags = vm_args,
-        **kwargs
-    )
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 866d74f..0f786a6 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.info;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
@@ -30,8 +32,6 @@
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -447,18 +447,8 @@
 
     public static void sortRevisionInfoByNumber(JsArray<RevisionInfo> list) {
       final int editParent = findEditParent(list);
-      Collections.sort(
-          Natives.asList(list),
-          new Comparator<RevisionInfo>() {
-            @Override
-            public int compare(RevisionInfo a, RevisionInfo b) {
-              return num(a) - num(b);
-            }
-
-            private int num(RevisionInfo r) {
-              return !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent;
-            }
-          });
+      Natives.asList(list)
+          .sort(comparing(r -> !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent));
     }
 
     public static int findEditParent(JsArray<RevisionInfo> list) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
index 345a260..fc3dbf1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
-import java.util.Collections;
 import java.util.Comparator;
 
 public class FileInfo extends JavaScriptObject {
@@ -55,8 +54,7 @@
   public final native void _row(int r) /*-{ this._row = r }-*/;
 
   public static void sortFileInfoByPath(JsArray<FileInfo> list) {
-    Collections.sort(
-        Natives.asList(list), Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
+    Natives.asList(list).sort(Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
   }
 
   public static String getFileName(String path) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
index 4b17068..41306ff 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.client.rpc;
 
+import static java.util.stream.Collectors.toCollection;
+
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
@@ -57,10 +58,7 @@
   }
 
   public final List<String> sortedKeys() {
-    Set<String> keys = keySet();
-    List<String> sorted = new ArrayList<>(keys);
-    Collections.sort(sorted);
-    return sorted;
+    return keySet().stream().sorted().collect(toCollection(ArrayList::new));
   }
 
   public final native JsArray<T> values() /*-{
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index 74fcdc2..38e1b60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -126,7 +126,25 @@
     if (size == 0) {
       return Resources.C.notAvailable();
     }
-    int p = Math.abs(Math.round(delta * 100 / size));
+    int p = Math.abs(saturatedCast(delta * 100 / size));
     return p + "%";
   }
+
+  /**
+   * Returns the {@code int} nearest in value to {@code value}.
+   *
+   * @param value any {@code long} value
+   * @return the same value cast to {@code int} if it is in the range of the {@code int} type,
+   *     {@link Integer#MAX_VALUE} if it is too large, or {@link Integer#MIN_VALUE} if it is too
+   *     small
+   */
+  private static int saturatedCast(long value) {
+    if (value > Integer.MAX_VALUE) {
+      return Integer.MAX_VALUE;
+    }
+    if (value < Integer.MIN_VALUE) {
+      return Integer.MIN_VALUE;
+    }
+    return (int) value;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
index b115c7d..88635df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
@@ -19,6 +19,8 @@
 public class ProjectAccessInfo extends JavaScriptObject {
   public final native boolean canAddRefs() /*-{ return this.can_add ? true : false; }-*/;
 
+  public final native boolean canAddTagRefs() /*-{ return this.can_add_tags ? true : false; }-*/;
+
   public final native boolean isOwner() /*-{ return this.is_owner ? true : false; }-*/;
 
   public final native boolean configVisible() /*-{ return this.config_visible ? true : false; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
index 0dc1dab..1a0090a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.account;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.GpgKeyInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -40,8 +42,6 @@
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class MyGpgKeysScreen extends SettingsScreen {
@@ -118,14 +118,7 @@
                     List<GpgKeyInfo> list = Natives.asList(result.values());
                     // TODO(dborowitz): Sort on something more meaningful, like
                     // created date?
-                    Collections.sort(
-                        list,
-                        new Comparator<GpgKeyInfo>() {
-                          @Override
-                          public int compare(GpgKeyInfo a, GpgKeyInfo b) {
-                            return a.id().compareTo(b.id());
-                          }
-                        });
+                    list.sort(comparing(GpgKeyInfo::id));
                     keys.clear();
                     keyText.setText("");
                     errorPanel.setVisible(false);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
index 5c6d40f..730d98e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.account;
 
+import static java.util.Comparator.naturalOrder;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -29,7 +31,6 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 
@@ -169,7 +170,7 @@
 
     void display(JsArray<ExternalIdInfo> results) {
       List<ExternalIdInfo> idList = Natives.asList(results);
-      Collections.sort(idList);
+      idList.sort(naturalOrder());
 
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
index 80c6d1a..4fdd067 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
@@ -41,7 +41,7 @@
   // corresponding regular expressions in the
   // com.google.gerrit.server.account.externalids.ExternalId class.
   private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
-  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9._@-]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
 
   private CopyableLabel userNameLbl;
   private NpTextBox userNameTxt;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index 1b946cd..7bd8b82 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.stream.Collectors.toCollection;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.LabelType;
@@ -45,7 +47,6 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.ValueListBox;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 public class AccessSectionEditor extends Composite
@@ -205,9 +206,8 @@
   }
 
   private void sortPermissions(AccessSection accessSection) {
-    List<Permission> permissionList = new ArrayList<>(accessSection.getPermissions());
-    Collections.sort(permissionList);
-    accessSection.setPermissions(permissionList);
+    accessSection.setPermissions(
+        accessSection.getPermissions().stream().sorted().collect(toCollection(ArrayList::new)));
   }
 
   void setEditing(boolean editing) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 2614224..6eaab5d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
@@ -29,7 +31,6 @@
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.client.ui.SmallHeading;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -295,26 +296,9 @@
 
     void insert(AccountInfo info) {
       Comparator<AccountInfo> c =
-          new Comparator<AccountInfo>() {
-            @Override
-            public int compare(AccountInfo a, AccountInfo b) {
-              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-              if (cmp != 0) {
-                return cmp;
-              }
-
-              cmp = nullToEmpty(a.email()).compareTo(nullToEmpty(b.email()));
-              if (cmp != 0) {
-                return cmp;
-              }
-
-              return a._accountId() - b._accountId();
-            }
-
-            public String nullToEmpty(String str) {
-              return str == null ? "" : str;
-            }
-          };
+          comparing((AccountInfo a) -> nullToEmpty(a.name()))
+              .thenComparing(a -> nullToEmpty(a.email()))
+              .thenComparing(AccountInfo::_accountId);
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -405,20 +389,7 @@
 
     void insert(GroupInfo info) {
       Comparator<GroupInfo> c =
-          new Comparator<GroupInfo>() {
-            @Override
-            public int compare(GroupInfo a, GroupInfo b) {
-              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-              if (cmp != 0) {
-                return cmp;
-              }
-              return a.getGroupUUID().compareTo(b.getGroupUUID());
-            }
-
-            private String nullToEmpty(@Nullable String str) {
-              return (str == null) ? "" : str;
-            }
-          };
+          comparing((GroupInfo g) -> nullToEmpty(g.name())).thenComparing(GroupInfo::getGroupUUID);
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -457,4 +428,9 @@
       setRowItem(row, i);
     }
   }
+
+  // Like Guava's Strings#nullToEmpty, which can't be used in GWT UI code.
+  private static String nullToEmpty(String str) {
+    return str == null ? "" : str;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
index 0c2f6fa..7b18a39 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
@@ -36,7 +36,7 @@
 
   String effectiveMaxObjectSizeLimit(String effectiveMaxObjectSizeLimit);
 
-  String globalMaxObjectSizeLimit(String globalMaxObjectSizeLimit);
+  String noMaxObjectSizeLimit();
 
   String pluginProjectOptionsTitle(String pluginName);
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
index 6338920..c9aa987 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
@@ -5,8 +5,8 @@
 deletedGroup = Deleted Group {0}
 deletedReference = Reference {0} was deleted
 deletedSection = Section {0} was deleted
-effectiveMaxObjectSizeLimit = effective: {0}
-globalMaxObjectSizeLimit = The global max object size limit is set to {0}. The limit cannot be increased on project level.
+effectiveMaxObjectSizeLimit = effective: {0} bytes
+noMaxObjectSizeLimit = No max object size limit is set.
 pluginProjectOptionsTitle = {0} Plugin Options
 pluginProjectOptionsTitle = {0} Plugin
 pluginProjectInheritedValue = inherited: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 259847e..be0db41 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupList;
@@ -32,8 +34,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.HTMLTable.Cell;
 import com.google.gwt.user.client.ui.Image;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class GroupTable extends NavigationTable<GroupInfo> {
@@ -105,14 +105,7 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(
-        list,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            return a.name().compareTo(b.name());
-          }
-        });
+    list.sort(comparing(GroupInfo::name));
     for (GroupInfo group : list.subList(fromIndex, toIndex)) {
       final int row = table.getRowCount();
       table.insertRow(row);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 751e951..d10a031 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -439,14 +439,14 @@
     setSubmitType(result.defaultSubmitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
-    if (result.maxObjectSizeLimit().inheritedValue() != null) {
-      effectiveMaxObjectSizeLimit.setVisible(true);
+    if (result.maxObjectSizeLimit().value() != null) {
       effectiveMaxObjectSizeLimit.setText(
           AdminMessages.I.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
-      effectiveMaxObjectSizeLimit.setTitle(
-          AdminMessages.I.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
+      if (result.maxObjectSizeLimit().summary() != null) {
+        effectiveMaxObjectSizeLimit.setTitle(result.maxObjectSizeLimit().summary());
+      }
     } else {
-      effectiveMaxObjectSizeLimit.setVisible(false);
+      effectiveMaxObjectSizeLimit.setText(AdminMessages.I.noMaxObjectSizeLimit());
     }
 
     saveProject.setEnabled(false);
@@ -512,6 +512,9 @@
       textBox.setValue(param.value());
       addWidget(g, textBox, param);
     }
+    if (textBox.getValue().length() > textBox.getVisibleLength()) {
+      textBox.setVisibleLength(textBox.getValue().length());
+    }
     saveEnabler.listenTo(textBox);
     return textBox;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
index 18e4176..22c331d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
@@ -94,7 +94,7 @@
         new GerritCallback<ProjectAccessInfo>() {
           @Override
           public void onSuccess(ProjectAccessInfo result) {
-            addPanel.setVisible(result.canAddRefs());
+            addPanel.setVisible(result.canAddTagRefs());
           }
         });
     query = new Query(match).start(start).run();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index 1f4820f..801a927 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.client.change;
 
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toCollection;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.Util;
@@ -135,9 +139,12 @@
   }
 
   void set(ChangeInfo info) {
-    List<String> names = new ArrayList<>(info.labels());
+    List<String> names =
+        info.labels()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
     Set<Integer> removable = info.removableReviewerIds();
-    Collections.sort(names);
 
     resize(names.size(), 2);
 
@@ -197,8 +204,7 @@
   }
 
   private static List<Integer> sort(Set<Integer> keySet, int a, int b) {
-    List<Integer> r = new ArrayList<>(keySet);
-    Collections.sort(r);
+    List<Integer> r = keySet.stream().sorted().collect(toCollection(ArrayList::new));
     if (keySet.contains(a)) {
       r.remove(Integer.valueOf(a));
       r.add(0, a);
@@ -238,31 +244,32 @@
       Set<Integer> removable,
       String label,
       Map<Integer, VotableInfo> votable) {
-    List<AccountInfo> users = new ArrayList<>(in);
-    Collections.sort(
-        users,
-        new Comparator<AccountInfo>() {
-          @Override
-          public int compare(AccountInfo a, AccountInfo b) {
-            String as = name(a);
-            String bs = name(b);
-            if (as.isEmpty()) {
-              return 1;
-            } else if (bs.isEmpty()) {
-              return -1;
-            }
-            return as.compareTo(bs);
-          }
+    List<AccountInfo> users =
+        in.stream()
+            .sorted(
+                new Comparator<AccountInfo>() {
+                  @Override
+                  public int compare(AccountInfo a, AccountInfo b) {
+                    String as = name(a);
+                    String bs = name(b);
+                    if (as.isEmpty()) {
+                      return 1;
+                    } else if (bs.isEmpty()) {
+                      return -1;
+                    }
+                    return as.compareTo(bs);
+                  }
 
-          private String name(AccountInfo a) {
-            if (a.name() != null) {
-              return a.name();
-            } else if (a.email() != null) {
-              return a.email();
-            }
-            return "";
-          }
-        });
+                  private String name(AccountInfo a) {
+                    if (a.name() != null) {
+                      return a.name();
+                    } else if (a.email() != null) {
+                      return a.email();
+                    }
+                    return "";
+                  }
+                })
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
 
     SafeHtmlBuilder html = new SafeHtmlBuilder();
     Iterator<? extends AccountInfo> itr = users.iterator();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
index 0bbd614..8a1a2d5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
@@ -16,6 +16,8 @@
 
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_ENTER;
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_MAC_ENTER;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.ChangeApi;
@@ -123,11 +125,15 @@
     this.lc = new LocalComments(project, psId.getParentKey());
     initWidget(uiBinder.createAndBindUi(this));
 
-    List<String> names = new ArrayList<>(permitted.keySet());
+    List<String> names =
+        permitted
+            .keySet()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
     if (names.isEmpty()) {
       UIObject.setVisible(labelsParent, false);
     } else {
-      Collections.sort(names);
       renderLabels(names, all, permitted);
     }
 
@@ -439,8 +445,11 @@
               clp, project, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
     }
 
-    List<String> paths = new ArrayList<>(m.keySet());
-    Collections.sort(paths);
+    List<String> paths =
+        m.keySet()
+            .stream()
+            .sorted()
+            .collect(collectingAndThen(toList(), Collections::unmodifiableList));
 
     for (String path : paths) {
       if (!Patch.isMagic(path)) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index c6e4e2f..7ec1102 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.changes;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.info.ChangeInfo;
@@ -176,7 +178,7 @@
       }
     }
 
-    Collections.sort(Natives.asList(out), outComparator());
+    Natives.asList(out).sort(outComparator());
 
     table.updateColumnsForLabels(wip, out, in, done);
     workInProgress.display(wip);
@@ -187,16 +189,7 @@
   }
 
   private Comparator<ChangeInfo> outComparator() {
-    return new Comparator<ChangeInfo>() {
-      @Override
-      public int compare(ChangeInfo a, ChangeInfo b) {
-        int cmp = a.created().compareTo(b.created());
-        if (cmp != 0) {
-          return cmp;
-        }
-        return a._number() - b._number();
-      }
-    };
+    return comparing(ChangeInfo::created).thenComparing(ChangeInfo::_number);
   }
 
   private boolean hasChanges(JsArray<ChangeList> result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index caea87e..4fda78b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -16,11 +16,13 @@
 
 import static com.google.gerrit.client.FormatUtil.relativeFormat;
 import static com.google.gerrit.client.FormatUtil.shortFormat;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.LabelInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountLinkPanel;
 import com.google.gerrit.client.ui.BranchLink;
 import com.google.gerrit.client.ui.ChangeLink;
@@ -45,6 +47,7 @@
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
@@ -185,17 +188,13 @@
   }
 
   public void updateColumnsForLabels(ChangeList... lists) {
-    labelNames = new ArrayList<>();
-    for (ChangeList list : lists) {
-      for (int i = 0; i < list.length(); i++) {
-        for (String name : list.get(i).labels()) {
-          if (!labelNames.contains(name)) {
-            labelNames.add(name);
-          }
-        }
-      }
-    }
-    Collections.sort(labelNames);
+    labelNames =
+        Arrays.stream(lists)
+            .flatMap(l -> Natives.asList(l).stream())
+            .flatMap(c -> c.labels().stream())
+            .distinct()
+            .sorted()
+            .collect(toList());
 
     int baseColumns = BASE_COLUMNS;
     if (baseColumns + labelNames.size() < columns) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
index 0e4ef4e..dcb9c01 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.dashboards;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.NavigationTable;
@@ -25,8 +27,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.Image;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -75,14 +75,7 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(
-        list,
-        new Comparator<DashboardInfo>() {
-          @Override
-          public int compare(DashboardInfo a, DashboardInfo b) {
-            return a.id().compareTo(b.id());
-          }
-        });
+    list.sort(comparing(DashboardInfo::id));
 
     String ref = null;
     for (DashboardInfo d : list) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index 533b745..2698584 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentApi;
@@ -27,8 +29,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-import java.util.Collections;
-import java.util.Comparator;
 
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
@@ -158,14 +158,7 @@
       for (CommentInfo c : Natives.asList(in)) {
         c.path(path);
       }
-      Collections.sort(
-          Natives.asList(in),
-          new Comparator<CommentInfo>() {
-            @Override
-            public int compare(CommentInfo a, CommentInfo b) {
-              return a.updated().compareTo(b.updated());
-            }
-          });
+      Natives.asList(in).sort(comparing(CommentInfo::updated));
     }
     return in;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 1a662e2..98ad023 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.diff.DiffInfo.Region;
 import com.google.gerrit.client.diff.DiffInfo.Span;
 import com.google.gerrit.client.rpc.Natives;
@@ -227,12 +229,7 @@
 
   /** Diff chunks are ordered by their starting lines in CodeMirror */
   private Comparator<UnifiedDiffChunkInfo> getDiffChunkComparatorCmLine() {
-    return new Comparator<UnifiedDiffChunkInfo>() {
-      @Override
-      public int compare(UnifiedDiffChunkInfo o1, UnifiedDiffChunkInfo o2) {
-        return o1.getCmLine() - o2.getCmLine();
-      }
-    };
+    return comparing(UnifiedDiffChunkInfo::getCmLine);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 4185ef3..684f8e6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -175,10 +175,10 @@
   public static class MaxObjectSizeLimitInfo extends JavaScriptObject {
     public final native String value() /*-{ return this.value; }-*/;
 
-    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
-
     public final native String configuredValue() /*-{ return this.configured_value }-*/;
 
+    public final native String summary() /*-{ return this.summary; }-*/;
+
     protected MaxObjectSizeLimitInfo() {}
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
index ac89180..3576b12 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.client.ui;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.projects.ProjectInfo;
 import com.google.gerrit.client.projects.ProjectMap;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.Image;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 public class ProjectsTable extends NavigationTable<ProjectInfo> {
@@ -69,14 +69,7 @@
     }
 
     List<ProjectInfo> list = Natives.asList(projects.values());
-    Collections.sort(
-        list,
-        new Comparator<ProjectInfo>() {
-          @Override
-          public int compare(ProjectInfo a, ProjectInfo b) {
-            return a.name().compareTo(b.name());
-          }
-        });
+    list.sort(comparing(ProjectInfo::name));
     for (ProjectInfo p : list.subList(fromIndex, toIndex)) {
       insert(table.getRowCount(), p);
     }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index c6f113e..9df066d 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -14,6 +14,8 @@
 
 package net.codemirror.mode;
 
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
@@ -21,8 +23,6 @@
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.resources.client.DataResource;
 import com.google.gwt.safehtml.shared.SafeUri;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -242,14 +242,7 @@
         byMime.put(m.mode(), m);
       }
     }
-    Collections.sort(
-        Natives.asList(filtered),
-        new Comparator<ModeInfo>() {
-          @Override
-          public int compare(ModeInfo a, ModeInfo b) {
-            return a.name().toLowerCase().compareTo(b.name().toLowerCase());
-          }
-        });
+    Natives.asList(filtered).sort(comparing(m -> m.name().toLowerCase()));
     setAll(filtered);
   }
 
diff --git a/java/Main.java b/java/Main.java
index f26b6df..11d8234 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -14,6 +14,7 @@
 
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -42,6 +43,9 @@
   }
 
   private static void configureFloggerBackend() {
+    System.setProperty(
+        FLOGGER_LOGGING_CONTEXT, "com.google.gerrit.server.logging.LoggingContext#getInstance");
+
     if (System.getProperty(FLOGGER_BACKEND_PROPERTY) != null) {
       // Flogger backend is already configured
       return;
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 1d87880..69d603f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1487,12 +1487,7 @@
     assertNotifyTo(expected.email, expected.fullName);
   }
 
-  protected void assertNotifyTo(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyTo(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
-  private void assertNotifyTo(String expectedEmail, String expectedFullname) {
+  protected void assertNotifyTo(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
@@ -1506,11 +1501,6 @@
     assertNotifyCc(expected.emailAddress);
   }
 
-  protected void assertNotifyCc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyCc(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
   protected void assertNotifyCc(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertNotifyCc(expectedAddress);
@@ -1533,13 +1523,10 @@
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
-  protected void assertNotifyBcc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
+  protected void assertNotifyBcc(String expectedEmail, String expectedFullName) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt())
-        .containsExactly(
-            new Address(expected.fullname().orElse(null), expected.preferredEmail().orElse(null)));
+    assertThat(m.rcpt()).containsExactly(new Address(expectedFullName, expectedEmail));
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index f9f95b5..5654c35 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -63,6 +63,7 @@
 
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event e) {
@@ -137,6 +138,10 @@
     return events;
   }
 
+  public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
+    getRefUpdatedEvents(project, branch, 0);
+  }
+
   public void assertRefUpdatedEvents(String project, String branch, String... expected)
       throws Exception {
     ImmutableList<RefUpdatedEvent> events =
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 6e5424c..582c7cb 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -24,6 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -441,6 +443,7 @@
             bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
             bind(AccountOperations.class).to(AccountOperationsImpl.class);
+            bind(GroupOperations.class).to(GroupOperationsImpl.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index b62e932..3e98d71 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.nio.ByteBuffer;
+import java.util.Arrays;
 import org.apache.http.Header;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -34,7 +39,7 @@
 
   public Reader getReader() throws IllegalStateException, IOException {
     if (reader == null && response.getEntity() != null) {
-      reader = new InputStreamReader(response.getEntity().getContent());
+      reader = new InputStreamReader(response.getEntity().getContent(), UTF_8);
     }
     return reader;
   }
@@ -59,6 +64,13 @@
     return hdr != null ? hdr.getValue() : null;
   }
 
+  public ImmutableList<String> getHeaders(String name) {
+    return Arrays.asList(response.getHeaders(name))
+        .stream()
+        .map(Header::getValue)
+        .collect(toImmutableList());
+  }
+
   public boolean hasContent() {
     Preconditions.checkNotNull(response, "Response is not initialized.");
     return response.getEntity() != null;
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 27dae3b..52d7f28 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.jcraft.jsch.ChannelExec;
@@ -54,10 +55,10 @@
       InputStream err = channel.getErrStream();
       channel.connect();
 
-      Scanner s = new Scanner(err).useDelimiter("\\A");
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
       error = s.hasNext() ? s.next() : null;
 
-      s = new Scanner(in).useDelimiter("\\A");
+      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
     } finally {
       channel.disconnect();
diff --git a/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java b/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java
new file mode 100644
index 0000000..5efdc81
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/ThrowingConsumer.java
@@ -0,0 +1,20 @@
+// 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 ThrowingConsumer<T> {
+  void accept(T t) throws Exception;
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index 58a00d0..61b7599 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -42,7 +42,7 @@
    * <p>Example:
    *
    * <pre>
-   * TestAccount createdAccount = accountOperations
+   * Account.Id createdAccountId = accountOperations
    *     .newAccount()
    *     .username("janedoe")
    *     .preferredEmail("janedoe@example.com")
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 3d741b0..ebbcfe4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -59,12 +59,12 @@
     return TestAccountCreation.builder(this::createAccount);
   }
 
-  private TestAccount createAccount(TestAccountCreation accountCreation) throws Exception {
+  private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
         (account, updateBuilder) ->
             fillBuilder(updateBuilder, accountCreation, account.getAccount().getId());
     AccountState createdAccount = createAccount(accountUpdater);
-    return toTestAccount(createdAccount);
+    return createdAccount.getAccount().getId();
   }
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
@@ -85,17 +85,6 @@
     accountCreation.active().ifPresent(builder::setActive);
   }
 
-  private static TestAccount toTestAccount(AccountState accountState) {
-    Account createdAccount = accountState.getAccount();
-    return TestAccount.builder()
-        .accountId(createdAccount.getId())
-        .preferredEmail(Optional.ofNullable(createdAccount.getPreferredEmail()))
-        .fullname(Optional.ofNullable(createdAccount.getFullName()))
-        .username(accountState.getUserName())
-        .active(accountState.getAccount().isActive())
-        .build();
-  }
-
   private static InternalAccountUpdate.Builder setPreferredEmail(
       InternalAccountUpdate.Builder builder, Account.Id accountId, String preferredEmail) {
     return builder
@@ -133,18 +122,28 @@
       return toTestAccount(account);
     }
 
+    private TestAccount toTestAccount(AccountState accountState) {
+      Account account = accountState.getAccount();
+      return TestAccount.builder()
+          .accountId(account.getId())
+          .preferredEmail(Optional.ofNullable(account.getPreferredEmail()))
+          .fullname(Optional.ofNullable(account.getFullName()))
+          .username(accountState.getUserName())
+          .active(accountState.getAccount().isActive())
+          .build();
+    }
+
     @Override
     public TestAccountUpdate.Builder forUpdate() {
       return TestAccountUpdate.builder(this::updateAccount);
     }
 
-    private TestAccount updateAccount(TestAccountUpdate accountUpdate)
+    private void updateAccount(TestAccountUpdate accountUpdate)
         throws OrmException, IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
           (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
-      return toTestAccount(updatedAccount.get());
     }
 
     private Optional<AccountState> updateAccount(AccountsUpdate.AccountUpdater accountUpdater)
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index a82d180..ab32409 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
 import java.util.Optional;
 
 @AutoValue
@@ -32,9 +33,9 @@
 
   public abstract Optional<Boolean> active();
 
-  abstract ThrowingFunction<TestAccountCreation, TestAccount> accountCreator();
+  abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
-  public static Builder builder(ThrowingFunction<TestAccountCreation, TestAccount> accountCreator) {
+  public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
     return new AutoValue_TestAccountCreation.Builder()
         .accountCreator(accountCreator)
         .httpPassword("http-pass");
@@ -83,11 +84,11 @@
     }
 
     abstract Builder accountCreator(
-        ThrowingFunction<TestAccountCreation, TestAccount> accountCreator);
+        ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
-    public TestAccount create() throws Exception {
+    public Account.Id create() throws Exception {
       TestAccountCreation accountUpdate = autoBuild();
       return accountUpdate.accountCreator().apply(accountUpdate);
     }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
index 517e4b5..251f452 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import java.util.Optional;
 
 @AutoValue
@@ -32,9 +32,9 @@
 
   public abstract Optional<Boolean> active();
 
-  abstract ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater();
+  abstract ThrowingConsumer<TestAccountUpdate> accountUpdater();
 
-  public static Builder builder(ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater) {
+  public static Builder builder(ThrowingConsumer<TestAccountUpdate> accountUpdater) {
     return new AutoValue_TestAccountUpdate.Builder()
         .accountUpdater(accountUpdater)
         .httpPassword("http-pass");
@@ -82,14 +82,13 @@
       return active(false);
     }
 
-    abstract Builder accountUpdater(
-        ThrowingFunction<TestAccountUpdate, TestAccount> accountUpdater);
+    abstract Builder accountUpdater(ThrowingConsumer<TestAccountUpdate> accountUpdater);
 
     abstract TestAccountUpdate autoBuild();
 
-    public TestAccount update() throws Exception {
+    public void update() throws Exception {
       TestAccountUpdate accountUpdate = autoBuild();
-      return accountUpdate.accountUpdater().apply(accountUpdate);
+      accountUpdate.accountUpdater().accept(accountUpdate);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
new file mode 100644
index 0000000..f75ca2e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+/**
+ * An aggregation of operations on groups for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface GroupOperations {
+  /**
+   * Starts the fluent chain for querying or modifying a group. Please see the methods of {@link
+   * MoreGroupOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific group
+   */
+  MoreGroupOperations group(AccountGroup.UUID groupUuid);
+
+  /**
+   * Starts the fluent chain to create a group. The returned builder can be used to specify the
+   * attributes of the new group. To create the group for real, {@link
+   * TestGroupCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * AccountGroup.UUID createdGroupUuid = groupOperations
+   *     .newGroup()
+   *     .name("verifiers")
+   *     .description("All verifiers of this server")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> If another group with the provided name already exists, the creation
+   * of the group will fail.
+   *
+   * @return a builder to create the new group
+   */
+  TestGroupCreation.Builder newGroup();
+
+  /** An aggregation of methods on a specific group. */
+  interface MoreGroupOperations {
+
+    /**
+     * Checks whether the group exists.
+     *
+     * @return {@code true} if the group exists
+     */
+    boolean exists() throws Exception;
+
+    /**
+     * Retrieves the group.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested group
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestGroup}
+     */
+    TestGroup get() throws Exception;
+
+    /**
+     * Starts the fluent chain to update a group. The returned builder can be used to specify how
+     * the attributes of the group should be modified. To update the group for real, {@link
+     * TestGroupUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * groupOperations.forUpdate().description("Another description for this group").update();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The update will fail with an exception if the group to update
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}.
+     *
+     * @return a builder to update the group
+     */
+    TestGroupUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
new file mode 100644
index 0000000..f9769c5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * The implementation of {@code GroupOperations}.
+ *
+ * <p>There is only one implementation of {@code GroupOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class GroupOperationsImpl implements GroupOperations {
+  private final Groups groups;
+  private final GroupsUpdate groupsUpdate;
+  private final Sequences seq;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  public GroupOperationsImpl(
+      Groups groups,
+      @ServerInitiated GroupsUpdate groupsUpdate,
+      Sequences seq,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this.groups = groups;
+    this.groupsUpdate = groupsUpdate;
+    this.seq = seq;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  public MoreGroupOperations group(AccountGroup.UUID groupUuid) {
+    return new MoreGroupOperationsImpl(groupUuid);
+  }
+
+  @Override
+  public TestGroupCreation.Builder newGroup() {
+    return TestGroupCreation.builder(this::createNewGroup);
+  }
+
+  private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
+      throws ConfigInvalidException, IOException, OrmException {
+    InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
+    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
+    InternalGroup internalGroup =
+        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    return internalGroup.getGroupUUID();
+  }
+
+  private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation)
+      throws OrmException {
+    AccountGroup.Id groupId = new AccountGroup.Id(seq.nextGroupId());
+    String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
+    AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
+    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(groupName);
+    return InternalGroupCreation.builder()
+        .setId(groupId)
+        .setGroupUUID(groupUuid)
+        .setNameKey(nameKey)
+        .build();
+  }
+
+  private static InternalGroupUpdate toInternalGroupUpdate(TestGroupCreation groupCreation) {
+    InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+    groupCreation.description().ifPresent(builder::setDescription);
+    groupCreation.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+    groupCreation.visibleToAll().ifPresent(builder::setVisibleToAll);
+    builder.setMemberModification(originalMembers -> groupCreation.members());
+    builder.setSubgroupModification(originalSubgroups -> groupCreation.subgroups());
+    return builder.build();
+  }
+
+  private class MoreGroupOperationsImpl implements MoreGroupOperations {
+    private final AccountGroup.UUID groupUuid;
+
+    MoreGroupOperationsImpl(AccountGroup.UUID groupUuid) {
+      this.groupUuid = groupUuid;
+    }
+
+    @Override
+    public boolean exists() throws Exception {
+      return groups.getGroup(groupUuid).isPresent();
+    }
+
+    @Override
+    public TestGroup get() throws Exception {
+      Optional<InternalGroup> group = groups.getGroup(groupUuid);
+      checkState(group.isPresent(), "Tried to get non-existing test group");
+      return toTestGroup(group.get());
+    }
+
+    private TestGroup toTestGroup(InternalGroup internalGroup) {
+      return TestGroup.builder()
+          .groupUuid(internalGroup.getGroupUUID())
+          .groupId(internalGroup.getId())
+          .nameKey(internalGroup.getNameKey())
+          .description(Optional.ofNullable(internalGroup.getDescription()))
+          .ownerGroupUuid(internalGroup.getOwnerGroupUUID())
+          .visibleToAll(internalGroup.isVisibleToAll())
+          .createdOn(internalGroup.getCreatedOn())
+          .members(internalGroup.getMembers())
+          .subgroups(internalGroup.getSubgroups())
+          .build();
+    }
+
+    @Override
+    public TestGroupUpdate.Builder forUpdate() {
+      return TestGroupUpdate.builder(this::updateGroup);
+    }
+
+    private void updateGroup(TestGroupUpdate groupUpdate)
+        throws OrmDuplicateKeyException, NoSuchGroupException, ConfigInvalidException, IOException {
+      InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, internalGroupUpdate);
+    }
+
+    private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
+      InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+      groupUpdate.name().map(AccountGroup.NameKey::new).ifPresent(builder::setName);
+      groupUpdate.description().ifPresent(builder::setDescription);
+      groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+      groupUpdate.visibleToAll().ifPresent(builder::setVisibleToAll);
+      builder.setMemberModification(groupUpdate.memberModification()::apply);
+      builder.setSubgroupModification(groupUpdate.subgroupModification()::apply);
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
new file mode 100644
index 0000000..b450304
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestGroup {
+
+  public abstract AccountGroup.UUID groupUuid();
+
+  public abstract AccountGroup.Id groupId();
+
+  public String name() {
+    return nameKey().get();
+  }
+
+  public abstract AccountGroup.NameKey nameKey();
+
+  public abstract Optional<String> description();
+
+  public abstract AccountGroup.UUID ownerGroupUuid();
+
+  public abstract boolean visibleToAll();
+
+  public abstract Timestamp createdOn();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  static Builder builder() {
+    return new AutoValue_TestGroup.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    public abstract Builder groupUuid(AccountGroup.UUID groupUuid);
+
+    public abstract Builder groupId(AccountGroup.Id id);
+
+    public abstract Builder nameKey(AccountGroup.NameKey name);
+
+    public abstract Builder description(String description);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder members(ImmutableSet<Account.Id> members);
+
+    public abstract Builder subgroups(ImmutableSet<AccountGroup.UUID> subgroups);
+
+    abstract TestGroup build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
new file mode 100644
index 0000000..efed720
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+
+@AutoValue
+public abstract class TestGroupCreation {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  abstract ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator();
+
+  public static Builder builder(
+      ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator) {
+    return new AutoValue_TestGroupCreation.Builder().groupCreator(groupCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public Builder clearMembers() {
+      return members(ImmutableSet.of());
+    }
+
+    public Builder members(Account.Id member1, Account.Id... otherMembers) {
+      return members(Sets.union(ImmutableSet.of(member1), ImmutableSet.copyOf(otherMembers)));
+    }
+
+    public abstract Builder members(Set<Account.Id> members);
+
+    abstract ImmutableSet.Builder<Account.Id> membersBuilder();
+
+    public Builder addMember(Account.Id member) {
+      membersBuilder().add(member);
+      return this;
+    }
+
+    public Builder clearSubgroups() {
+      return subgroups(ImmutableSet.of());
+    }
+
+    public Builder subgroups(AccountGroup.UUID subgroup1, AccountGroup.UUID... otherSubgroups) {
+      return subgroups(Sets.union(ImmutableSet.of(subgroup1), ImmutableSet.copyOf(otherSubgroups)));
+    }
+
+    public abstract Builder subgroups(Set<AccountGroup.UUID> subgroups);
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> subgroupsBuilder();
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      subgroupsBuilder().add(subgroup);
+      return this;
+    }
+
+    abstract Builder groupCreator(
+        ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator);
+
+    abstract TestGroupCreation autoBuild();
+
+    /**
+     * Executes the group creation as specified.
+     *
+     * @return the UUID of the created group
+     */
+    public AccountGroup.UUID create() throws Exception {
+      TestGroupCreation groupCreation = autoBuild();
+      return groupCreation.groupCreator().apply(groupCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
new file mode 100644
index 0000000..095a270
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+
+@AutoValue
+public abstract class TestGroupUpdate {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+  public abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+      subgroupModification();
+
+  abstract ThrowingConsumer<TestGroupUpdate> groupUpdater();
+
+  public static Builder builder(ThrowingConsumer<TestGroupUpdate> groupUpdater) {
+    return new AutoValue_TestGroupUpdate.Builder()
+        .groupUpdater(groupUpdater)
+        .memberModification(in -> in)
+        .subgroupModification(in -> in);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUUID);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    abstract Builder memberModification(
+        Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification);
+
+    abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+    public Builder clearMembers() {
+      return memberModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.union(previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    public Builder removeMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.difference(
+                  previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    abstract Builder subgroupModification(
+        Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> subgroupModification);
+
+    abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+        subgroupModification();
+
+    public Builder clearSubgroups() {
+      return subgroupModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.union(previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    public Builder removeSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.difference(
+                  previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    abstract Builder groupUpdater(ThrowingConsumer<TestGroupUpdate> groupUpdater);
+
+    abstract TestGroupUpdate autoBuild();
+
+    /** Executes the group update as specified. */
+    public void update() throws Exception {
+      TestGroupUpdate groupUpdater = autoBuild();
+      groupUpdater.groupUpdater().accept(groupUpdater);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 7bfd22e..ff7d25b 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 
 public class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
@@ -34,6 +36,7 @@
   public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
     checkName(name);
@@ -70,22 +73,13 @@
 
   private static List<LabelValue> sortValues(List<LabelValue> values) {
     values = new ArrayList<>(values);
-    if (values.size() <= 1) {
-      return Collections.unmodifiableList(values);
+    if (values.isEmpty()) {
+      return Collections.emptyList();
     }
-    Collections.sort(
-        values,
-        new Comparator<LabelValue>() {
-          @Override
-          public int compare(LabelValue o1, LabelValue o2) {
-            return o1.getValue() - o2.getValue();
-          }
-        });
-    short min = values.get(0).getValue();
-    short max = values.get(values.size() - 1).getValue();
-    short v = min;
+    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
+    short v = values.get(0).getValue();
     short i = 0;
-    List<LabelValue> result = new ArrayList<>(max - min + 1);
+    ArrayList<LabelValue> result = new ArrayList<>();
     // Fill in any missing values with empty text.
     while (i < values.size()) {
       while (v < values.get(i).getValue()) {
@@ -94,6 +88,7 @@
       v++;
       result.add(values.get(i++));
     }
+    result.trimToSize();
     return Collections.unmodifiableList(result);
   }
 
@@ -109,6 +104,7 @@
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
   protected boolean allowPostSubmit;
+  protected boolean ignoreSelfApproval;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -117,7 +113,6 @@
 
   private transient boolean canOverride;
   private transient List<String> refPatterns;
-  private transient List<Integer> intList;
   private transient Map<Short, LabelValue> byValue;
 
   protected LabelType() {}
@@ -148,6 +143,12 @@
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
+    setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
+
+    byValue = new HashMap<>();
+    for (LabelValue v : values) {
+      byValue.put(v.getValue(), v);
+    }
   }
 
   public String getName() {
@@ -162,11 +163,8 @@
     if (functionName == null) {
       return null;
     }
-    Optional<LabelFunction> f = LabelFunction.parse(functionName);
-    if (!f.isPresent()) {
-      throw new IllegalStateException("Unsupported functionName: " + functionName);
-    }
-    return f.get();
+    return LabelFunction.parse(functionName)
+        .orElseThrow(() -> new IllegalStateException("Unsupported functionName: " + functionName));
   }
 
   public void setFunction(@Nullable LabelFunction function) {
@@ -193,8 +191,21 @@
     this.allowPostSubmit = allowPostSubmit;
   }
 
+  public boolean ignoreSelfApproval() {
+    return ignoreSelfApproval;
+  }
+
+  public void setIgnoreSelfApproval(boolean ignoreSelfApproval) {
+    this.ignoreSelfApproval = ignoreSelfApproval;
+  }
+
   public void setRefPatterns(List<String> refPatterns) {
-    this.refPatterns = refPatterns;
+    if (refPatterns != null) {
+      this.refPatterns =
+          refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
+    } else {
+      this.refPatterns = null;
+    }
   }
 
   public List<LabelValue> getValues() {
@@ -281,36 +292,13 @@
   }
 
   public LabelValue getValue(short value) {
-    initByValue();
     return byValue.get(value);
   }
 
   public LabelValue getValue(PatchSetApproval ca) {
-    initByValue();
     return byValue.get(ca.getValue());
   }
 
-  private void initByValue() {
-    if (byValue == null) {
-      byValue = new HashMap<>();
-      for (LabelValue v : values) {
-        byValue.put(v.getValue(), v);
-      }
-    }
-  }
-
-  public List<Integer> getValuesAsList() {
-    if (intList == null) {
-      intList = new ArrayList<>(values.size());
-      for (LabelValue v : values) {
-        intList.add(Integer.valueOf(v.getValue()));
-      }
-      Collections.sort(intList);
-      Collections.reverse(intList);
-    }
-    return intList;
-  }
-
   public LabelId getLabelId() {
     return new LabelId(name);
   }
diff --git a/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
index 811e751..c0ba781 100644
--- a/java/com/google/gerrit/common/data/LabelValue.java
+++ b/java/com/google/gerrit/common/data/LabelValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import java.util.Objects;
+
 public class LabelValue {
   public static String formatValue(short value) {
     if (value < 0) {
@@ -56,6 +58,20 @@
   }
 
   @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof LabelValue)) {
+      return false;
+    }
+    LabelValue v = (LabelValue) o;
+    return value == v.value && Objects.equals(text, v.text);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value, text);
+  }
+
+  @Override
   public String toString() {
     return format();
   }
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index f322c3d..2c1c93a 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -71,6 +71,7 @@
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.ContentType;
 import org.apache.http.nio.entity.NStringEntity;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
@@ -98,7 +99,7 @@
     String content = "";
     if (responseEntity != null) {
       InputStream contentStream = responseEntity.getContent();
-      try (Reader reader = new InputStreamReader(contentStream)) {
+      try (Reader reader = new InputStreamReader(contentStream, UTF_8)) {
         content = CharStreams.toString(reader);
       }
     }
@@ -171,10 +172,10 @@
   public void deleteAll() throws IOException {
     // Delete the index, if it exists.
     String endpoint = indexName + client.adapter().indicesExistParam();
-    Response response = client.get().performRequest("HEAD", endpoint);
+    Response response = client.get().performRequest(new Request("HEAD", endpoint));
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode == HttpStatus.SC_OK) {
-      response = client.get().performRequest("DELETE", indexName);
+      response = client.get().performRequest(new Request("DELETE", indexName));
       statusCode = response.getStatusLine().getStatusCode();
       if (statusCode != HttpStatus.SC_OK) {
         throw new IOException(
@@ -307,9 +308,13 @@
 
   private Response performRequest(
       String method, Object payload, String uri, Map<String, String> params) throws IOException {
+    Request request = new Request(method, uri);
     String payloadStr = payload instanceof String ? (String) payload : payload.toString();
-    HttpEntity entity = new NStringEntity(payloadStr, ContentType.APPLICATION_JSON);
-    return client.get().performRequest(method, uri, params, entity);
+    request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
+    for (Map.Entry<String, String> entry : params.entrySet()) {
+      request.addParameter(entry.getKey(), entry.getValue());
+    }
+    return client.get().performRequest(request);
   }
 
   protected class ElasticQuerySource implements DataSource<V> {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
index e36ab2d..a777f47 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
@@ -24,7 +24,7 @@
 import java.util.List;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
-import org.apache.http.client.methods.HttpGet;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 
 @Singleton
@@ -40,10 +40,8 @@
 
   List<String> discover(String prefix, String indexName) throws IOException {
     String name = prefix + indexName + "_";
-    Response response =
-        client
-            .get()
-            .performRequest(HttpGet.METHOD_NAME, client.adapter().getVersionDiscoveryUrl(name));
+    Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name));
+    Response response = client.get().performRequest(request);
 
     StatusLine statusLine = response.getStatusLine();
     if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index 2beb528..8cb69e0 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -32,13 +32,14 @@
 
   ElasticQueryAdapter(ElasticVersion version) {
     this.ignoreUnmapped = version == ElasticVersion.V2_4;
-    this.usePostV5Type = isV6(version);
-    this.versionDiscoveryUrl = isV6(version) ? "%s*" : "%s*/_aliases";
+    this.usePostV5Type = version.isV6();
+    this.versionDiscoveryUrl = version.isV6() ? "%s*" : "%s*/_aliases";
 
     switch (version) {
       case V5_6:
       case V6_2:
       case V6_3:
+      case V6_4:
         this.searchFilteringName = "_source";
         this.indicesExistParam = "?allow_no_indices=false";
         this.exactFieldType = "keyword";
@@ -58,10 +59,6 @@
     }
   }
 
-  private boolean isV6(ElasticVersion version) {
-    return version == ElasticVersion.V6_2 || version == ElasticVersion.V6_3;
-  }
-
   void setIgnoreUnmapped(JsonObject properties) {
     if (ignoreUnmapped) {
       properties.addProperty("ignore_unmapped", true);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index db59257..337f2ca 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -29,6 +29,7 @@
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 import org.elasticsearch.client.RestClient;
 import org.elasticsearch.client.RestClientBuilder;
@@ -39,7 +40,7 @@
 
   private final ElasticConfiguration cfg;
 
-  private RestClient client;
+  private volatile RestClient client;
   private ElasticQueryAdapter adapter;
 
   @Inject
@@ -105,7 +106,7 @@
 
   private ElasticVersion getVersion() throws ElasticException {
     try {
-      Response response = client.performRequest("GET", "");
+      Response response = client.performRequest(new Request("GET", ""));
       StatusLine statusLine = response.getStatusLine();
       if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
         throw new FailedToGetVersion(statusLine);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index 610a212..dfa5d21 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -21,7 +21,8 @@
   V2_4("2.4.*"),
   V5_6("5.6.*"),
   V6_2("6.2.*"),
-  V6_3("6.3.*");
+  V6_3("6.3.*"),
+  V6_4("6.4.*");
 
   private final String version;
   private final Pattern pattern;
@@ -31,29 +32,33 @@
     this.pattern = Pattern.compile(version);
   }
 
-  public static class InvalidVersion extends ElasticException {
+  public static class UnsupportedVersion extends ElasticException {
     private static final long serialVersionUID = 1L;
 
-    InvalidVersion(String version) {
+    UnsupportedVersion(String version) {
       super(
           String.format(
-              "Invalid version: [%s]. Supported versions: %s", version, supportedVersions()));
+              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
     }
   }
 
-  public static ElasticVersion forVersion(String version) throws InvalidVersion {
+  public static ElasticVersion forVersion(String version) throws UnsupportedVersion {
     for (ElasticVersion value : ElasticVersion.values()) {
       if (value.pattern.matcher(version).matches()) {
         return value;
       }
     }
-    throw new InvalidVersion(version);
+    throw new UnsupportedVersion(version);
   }
 
   public static String supportedVersions() {
     return Joiner.on(", ").join(ElasticVersion.values());
   }
 
+  public boolean isV6() {
+    return version.startsWith("6.");
+  }
+
   @Override
   public String toString() {
     return version;
diff --git a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
new file mode 100644
index 0000000..aa31dd0
--- /dev/null
+++ b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.annotations;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for features that are deprecated, but still present to adhere to the one-release-grace
+ * period we promised to users.
+ */
+@Target({ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
+@Retention(SOURCE)
+@BindingAnnotation
+public @interface RemoveAfter {
+  /**
+   * Version after which the annotated functionality can be removed. Once the referenced version was
+   * branched off, the annotated code can be removed.
+   */
+  String value();
+}
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
index 133de31..02afbdc 100644
--- a/java/com/google/gerrit/extensions/api/access/GerritPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
@@ -18,7 +18,13 @@
 
 /** Gerrit permission for hosts, projects, refs, changes, labels and plugins. */
 public interface GerritPermission {
-  /** @return readable identifier of this permission for exception message. */
+  /**
+   * A description in the context of an exception message.
+   *
+   * <p>Should be grammatical when used in the construction "not permitted: [description] on
+   * [resource]", although individual {@code PermissionBackend} implementations may vary the
+   * wording.
+   */
   String describeForException();
 
   static String describeEnumValue(Enum<?> value) {
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 5d8e950..8273d84 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -29,6 +29,7 @@
   public Set<String> ownerOf;
   public Boolean canUpload;
   public Boolean canAdd;
+  public Boolean canAddTags;
   public Boolean configVisible;
   public Map<String, GroupInfo> groups;
   public List<WebLinkInfo> configWebLinks;
diff --git a/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java b/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
index d876034..8fe47bd 100644
--- a/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -23,9 +24,11 @@
   public Map<String, Collection<String>> external;
 
   public IncludedInInfo(
-      List<String> branches, List<String> tags, Map<String, Collection<String>> external) {
-    this.branches = branches;
-    this.tags = tags;
+      Collection<String> branches,
+      Collection<String> tags,
+      Map<String, Collection<String>> external) {
+    this.branches = new ArrayList<>(branches);
+    this.tags = new ArrayList<>(tags);
     this.external = external;
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
new file mode 100644
index 0000000..145b200
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class CheckProjectInput {
+  public AutoCloseableChangesCheckInput autoCloseableChangesCheck;
+
+  public static class AutoCloseableChangesCheckInput {
+    /** Whether auto-closeable changes should be fixed by setting their status to MERGED. */
+    public Boolean fix;
+
+    /** Branch that should be checked for auto-closeable changes. */
+    public String branch;
+
+    /** Number of commits to skip. */
+    public Integer skipCommits;
+
+    /** Maximum number of commits to walk. */
+    public Integer maxCommits;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
new file mode 100644
index 0000000..e685122
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import java.util.List;
+
+public class CheckProjectResultInfo {
+  public AutoCloseableChangesCheckResult autoCloseableChangesCheckResult;
+
+  public static class AutoCloseableChangesCheckResult {
+    public List<ChangeInfo> autoCloseableChanges;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index b3dd1f1..08ba486 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -58,9 +59,17 @@
   }
 
   public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
+    /** The effective value in bytes. Null if not set. */
+    @Nullable public String value;
+
+    /** The value configured explicitly on the project as a formatted string. Null if not set. */
+    @Nullable public String configuredValue;
+
+    /**
+     * Whether the value was inherited or overridden from the project's parent hierarchy or global
+     * config. Null if not inherited or overridden.
+     */
+    @Nullable public String summary;
   }
 
   public static class ConfigParameterInfo {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 63d40f0..0139b52 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -43,6 +43,8 @@
 
   AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
 
+  CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException;
+
   ConfigInfo config() throws RestApiException;
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
@@ -243,6 +245,11 @@
     }
 
     @Override
+    public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ConfigInfo config() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
index a940403..53f0375 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.base.MoreObjects;
 import java.util.Map;
 import java.util.Objects;
 
@@ -50,4 +51,14 @@
   public int hashCode() {
     return Objects.hash(status, fallbackText, type, data);
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("status", status)
+        .add("fallbackText", fallbackText)
+        .add("type", type)
+        .add("data", data)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 9030a1c..5fc8ba6 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
@@ -81,4 +82,10 @@
     ContentEntry contentEntry = actual();
     return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
   }
+
+  public IntegerSubject numberOfSkippedLines() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.skip).named("number of skipped lines");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
index e46ceb8..2da6ec9 100644
--- a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface PrivateStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onPrivateStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
index e957421..d0e2bc1 100644
--- a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface WorkInProgressStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onWorkInProgressStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 477b666..4f36ab4 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -34,17 +35,6 @@
  * exception is thrown.
  */
 public class DynamicItem<T> {
-  /** Pair of provider implementation and plugin providing it. */
-  static class NamedProvider<T> {
-    final Provider<T> impl;
-    final String pluginName;
-
-    NamedProvider(Provider<T> provider, String pluginName) {
-      this.impl = provider;
-      this.pluginName = pluginName;
-    }
-  }
-
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
    *
@@ -88,7 +78,8 @@
    * @param item item to store.
    */
   public static <T> DynamicItem<T> itemOf(Class<T> member, T item) {
-    return new DynamicItem<>(keyFor(TypeLiteral.get(member)), Providers.of(item), "gerrit");
+    return new DynamicItem<>(
+        keyFor(TypeLiteral.get(member)), Providers.of(item), PluginName.GERRIT);
   }
 
   @SuppressWarnings("unchecked")
@@ -137,12 +128,26 @@
    * @return the configured item instance; null if no implementation has been bound to the item.
    *     This is common if no plugin registered an implementation for the type.
    */
+  @Nullable
   public T get() {
     NamedProvider<T> item = ref.get();
     return item != null ? item.impl.get() : null;
   }
 
   /**
+   * Get the name of the plugin that has bound the configured item, or null.
+   *
+   * @return the name of the plugin that has bound the configured item; null if no implementation
+   *     has been bound to the item. This is common if no plugin registered an implementation for
+   *     the type.
+   */
+  @Nullable
+  public String getPluginName() {
+    NamedProvider<T> item = ref.get();
+    return item != null ? item.pluginName : null;
+  }
+
+  /**
    * Set the element to provide.
    *
    * @param item the item to use. Must not be null.
@@ -165,7 +170,7 @@
     NamedProvider<T> old = null;
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
-      if (old != null && !"gerrit".equals(old.pluginName)) {
+      if (old != null && !PluginName.GERRIT.equals(old.pluginName)) {
         throw new ProvisionException(
             String.format(
                 "%s already provided by %s, ignoring plugin %s",
@@ -197,7 +202,9 @@
     NamedProvider<T> old = null;
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
-      if (old != null && !"gerrit".equals(old.pluginName) && !pluginName.equals(old.pluginName)) {
+      if (old != null
+          && !PluginName.GERRIT.equals(old.pluginName)
+          && !pluginName.equals(old.pluginName)) {
         // We allow to replace:
         // 1. Gerrit core items, e.g. websession cache
         //    can be replaced by plugin implementation
@@ -233,6 +240,7 @@
     }
 
     @Override
+    @Nullable
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       NamedProvider<T> n = new NamedProvider<>(newItem, item.pluginName);
       if (ref.compareAndSet(item, n)) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index 5b76741..d8dd1f9 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -36,7 +36,7 @@
 
   @Override
   public DynamicItem<T> get() {
-    return new DynamicItem<>(key, find(injector, type), "gerrit");
+    return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
   }
 
   private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index 7178a16..96d19b2 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -41,6 +41,28 @@
  * singleton and non-singleton members.
  */
 public abstract class DynamicMap<T> implements Iterable<DynamicMap.Entry<T>> {
+  public static class Entry<T> {
+    private final NamePair namePair;
+    private final Provider<T> provider;
+
+    private Entry(NamePair namePair, Provider<T> provider) {
+      this.namePair = namePair;
+      this.provider = provider;
+    }
+
+    public String getPluginName() {
+      return namePair.pluginName;
+    }
+
+    public String getExportName() {
+      return namePair.exportName;
+    }
+
+    public Provider<T> getProvider() {
+      return provider;
+    }
+  }
+
   /**
    * Declare a singleton {@code DynamicMap<T>} with a binder.
    *
@@ -154,23 +176,8 @@
 
       @Override
       public Entry<T> next() {
-        final Map.Entry<NamePair, Provider<T>> e = i.next();
-        return new Entry<T>() {
-          @Override
-          public String getPluginName() {
-            return e.getKey().pluginName;
-          }
-
-          @Override
-          public String getExportName() {
-            return e.getKey().exportName;
-          }
-
-          @Override
-          public Provider<T> getProvider() {
-            return e.getValue();
-          }
-        };
+        Map.Entry<NamePair, Provider<T>> e = i.next();
+        return new Entry<>(e.getKey(), e.getValue());
       }
 
       @Override
@@ -180,14 +187,6 @@
     };
   }
 
-  public interface Entry<T> {
-    String getPluginName();
-
-    String getExportName();
-
-    Provider<T> getProvider();
-  }
-
   static class NamePair {
     private final String pluginName;
     private final String exportName;
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java b/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
index 420a356..9d96131 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
@@ -37,7 +37,7 @@
     if (bindings != null) {
       for (Binding<T> b : bindings) {
         if (b.getKey().getAnnotation() != null) {
-          m.put("gerrit", b.getKey(), b.getProvider());
+          m.put(PluginName.GERRIT, b.getKey(), b.getProvider());
         }
       }
     }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 7ffb86d..6b3a49b 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -14,6 +14,12 @@
 
 package com.google.gerrit.extensions.registration;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.naturalOrder;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -39,6 +45,24 @@
  * singleton and non-singleton members.
  */
 public class DynamicSet<T> implements Iterable<T> {
+  public static class Entry<T> {
+    private final String pluginName;
+    private final Provider<T> provider;
+
+    private Entry(String pluginName, Provider<T> provider) {
+      this.pluginName = pluginName;
+      this.provider = provider;
+    }
+
+    public String getPluginName() {
+      return pluginName;
+    }
+
+    public Provider<T> getProvider() {
+      return provider;
+    }
+  }
+
   /**
    * Declare a singleton {@code DynamicSet<T>} with a binder.
    *
@@ -129,12 +153,12 @@
   }
 
   public static <T> DynamicSet<T> emptySet() {
-    return new DynamicSet<>(Collections.<AtomicReference<Provider<T>>>emptySet());
+    return new DynamicSet<>(Collections.<AtomicReference<NamedProvider<T>>>emptySet());
   }
 
-  private final CopyOnWriteArrayList<AtomicReference<Provider<T>>> items;
+  private final CopyOnWriteArrayList<AtomicReference<NamedProvider<T>>> items;
 
-  DynamicSet(Collection<AtomicReference<Provider<T>>> base) {
+  DynamicSet(Collection<AtomicReference<NamedProvider<T>>> base) {
     items = new CopyOnWriteArrayList<>(base);
   }
 
@@ -144,38 +168,59 @@
 
   @Override
   public Iterator<T> iterator() {
-    final Iterator<AtomicReference<Provider<T>>> itr = items.iterator();
+    Iterator<Entry<T>> entryIterator = entries().iterator();
     return new Iterator<T>() {
-      private T next;
-
       @Override
       public boolean hasNext() {
-        while (next == null && itr.hasNext()) {
-          Provider<T> p = itr.next().get();
-          if (p != null) {
-            try {
-              next = p.get();
-            } catch (RuntimeException e) {
-              // TODO Log failed member of DynamicSet.
-            }
-          }
-        }
-        return next != null;
+        return entryIterator.hasNext();
       }
 
       @Override
       public T next() {
-        if (hasNext()) {
-          T result = next;
-          next = null;
-          return result;
-        }
-        throw new NoSuchElementException();
+        Entry<T> next = entryIterator.next();
+        return next != null ? next.getProvider().get() : null;
       }
+    };
+  }
 
+  public Iterable<Entry<T>> entries() {
+    final Iterator<AtomicReference<NamedProvider<T>>> itr = items.iterator();
+    return new Iterable<Entry<T>>() {
       @Override
-      public void remove() {
-        throw new UnsupportedOperationException();
+      public Iterator<Entry<T>> iterator() {
+        return new Iterator<Entry<T>>() {
+          private Entry<T> next;
+
+          @Override
+          public boolean hasNext() {
+            while (next == null && itr.hasNext()) {
+              NamedProvider<T> p = itr.next().get();
+              if (p != null) {
+                try {
+                  next = new Entry<>(p.pluginName, p.impl);
+                } catch (RuntimeException e) {
+                  // TODO Log failed member of DynamicSet.
+                }
+              }
+            }
+            return next != null;
+          }
+
+          @Override
+          public Entry<T> next() {
+            if (hasNext()) {
+              Entry<T> result = next;
+              next = null;
+              return result;
+            }
+            throw new NoSuchElementException();
+          }
+
+          @Override
+          public void remove() {
+            throw new UnsupportedOperationException();
+          }
+        };
       }
     };
   }
@@ -198,13 +243,29 @@
   }
 
   /**
-   * Add one new element to the set.
+   * Get the names of all running plugins supplying this type.
    *
-   * @param item the item to add to the collection. Must not be null.
-   * @return handle to remove the item at a later point in time.
+   * @return sorted set of active plugins that supply at least one item.
    */
-  public RegistrationHandle add(T item) {
-    return add(Providers.of(item));
+  public ImmutableSortedSet<String> plugins() {
+    return items
+        .stream()
+        .map(i -> i.get().pluginName)
+        .collect(toImmutableSortedSet(naturalOrder()));
+  }
+
+  /**
+   * Get the items exported by a single plugin.
+   *
+   * @param pluginName name of the plugin.
+   * @return items exported by a plugin.
+   */
+  public ImmutableSet<Provider<T>> byPlugin(String pluginName) {
+    return items
+        .stream()
+        .filter(i -> i.get().pluginName.equals(pluginName))
+        .map(i -> i.get().impl)
+        .collect(toImmutableSet());
   }
 
   /**
@@ -213,13 +274,24 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle add(Provider<T> item) {
-    final AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
+  public RegistrationHandle add(String pluginName, T item) {
+    return add(pluginName, Providers.of(item));
+  }
+
+  /**
+   * Add one new element to the set.
+   *
+   * @param item the item to add to the collection. Must not be null.
+   * @return handle to remove the item at a later point in time.
+   */
+  public RegistrationHandle add(String pluginName, Provider<T> item) {
+    final AtomicReference<NamedProvider<T>> ref =
+        new AtomicReference<>(new NamedProvider<>(item, pluginName));
     items.add(ref);
     return new RegistrationHandle() {
       @Override
       public void remove() {
-        if (ref.compareAndSet(item, null)) {
+        if (ref.compareAndSet(ref.get(), null)) {
           items.remove(ref);
         }
       }
@@ -229,6 +301,7 @@
   /**
    * Add one new element that may be hot-replaceable in the future.
    *
+   * @param pluginName unique name of the plugin providing the item.
    * @param key unique description from the item's Guice binding. This can be later obtained from
    *     the registration handle to facilitate matching with the new equivalent instance during a
    *     hot reload.
@@ -236,18 +309,19 @@
    * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
    *     the collection.
    */
-  public ReloadableRegistrationHandle<T> add(Key<T> key, Provider<T> item) {
-    AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
+  public ReloadableRegistrationHandle<T> add(String pluginName, Key<T> key, Provider<T> item) {
+    AtomicReference<NamedProvider<T>> ref =
+        new AtomicReference<>(new NamedProvider<>(item, pluginName));
     items.add(ref);
-    return new ReloadableHandle(ref, key, item);
+    return new ReloadableHandle(ref, key, ref.get());
   }
 
   private class ReloadableHandle implements ReloadableRegistrationHandle<T> {
-    private final AtomicReference<Provider<T>> ref;
+    private final AtomicReference<NamedProvider<T>> ref;
     private final Key<T> key;
-    private final Provider<T> item;
+    private final NamedProvider<T> item;
 
-    ReloadableHandle(AtomicReference<Provider<T>> ref, Key<T> key, Provider<T> item) {
+    ReloadableHandle(AtomicReference<NamedProvider<T>> ref, Key<T> key, NamedProvider<T> item) {
       this.ref = ref;
       this.key = key;
       this.item = item;
@@ -267,8 +341,9 @@
 
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
-      if (ref.compareAndSet(item, newItem)) {
-        return new ReloadableHandle(ref, newKey, newItem);
+      NamedProvider<T> n = new NamedProvider<>(newItem, item.pluginName);
+      if (ref.compareAndSet(item, n)) {
+        return new ReloadableHandle(ref, newKey, n);
       }
       return null;
     }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index 707c76a..6d36f54 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -38,16 +38,17 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Provider<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> List<AtomicReference<NamedProvider<T>>> find(
+      Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     int cnt = bindings != null ? bindings.size() : 0;
     if (cnt == 0) {
       return Collections.emptyList();
     }
-    List<AtomicReference<Provider<T>>> r = new ArrayList<>(cnt);
+    List<AtomicReference<NamedProvider<T>>> r = new ArrayList<>(cnt);
     for (Binding<T> b : bindings) {
       if (b.getKey().getAnnotation() != null) {
-        r.add(new AtomicReference<>(b.getProvider()));
+        r.add(new AtomicReference<>(new NamedProvider<>(b.getProvider(), PluginName.GERRIT)));
       }
     }
     return r;
diff --git a/java/com/google/gerrit/extensions/registration/NamedProvider.java b/java/com/google/gerrit/extensions/registration/NamedProvider.java
new file mode 100644
index 0000000..aca651b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/registration/NamedProvider.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.registration;
+
+import com.google.inject.Provider;
+
+/** Pair of provider implementation and plugin providing it. */
+class NamedProvider<T> {
+  final Provider<T> impl;
+  final String pluginName;
+
+  NamedProvider(Provider<T> provider, String pluginName) {
+    this.impl = provider;
+    this.pluginName = pluginName;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/registration/PluginName.java b/java/com/google/gerrit/extensions/registration/PluginName.java
new file mode 100644
index 0000000..c110d45
--- /dev/null
+++ b/java/com/google/gerrit/extensions/registration/PluginName.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.registration;
+
+public class PluginName {
+  /** Name that is used as plugin name if Gerrit core implements a plugin extension point. */
+  public static final String GERRIT = "gerrit";
+
+  private PluginName() {}
+}
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index e606079..fd31fcd 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -81,7 +81,7 @@
   }
 
   public static List<RegistrationHandle> attachItems(
-      Injector src, Map<TypeLiteral<?>, DynamicItem<?>> items, String pluginName) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
       return Collections.emptyList();
     }
@@ -107,7 +107,7 @@
   }
 
   public static List<RegistrationHandle> attachSets(
-      Injector src, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
       return Collections.emptyList();
     }
@@ -123,7 +123,7 @@
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.add(b.getKey(), b.getProvider()));
+            handles.add(set.add(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -135,7 +135,7 @@
   }
 
   public static List<RegistrationHandle> attachMaps(
-      Injector src, String groupName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
+      Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
       return Collections.emptyList();
     }
@@ -147,12 +147,12 @@
         TypeLiteral<Object> type = (TypeLiteral<Object>) e.getKey();
 
         @SuppressWarnings("unchecked")
-        PrivateInternals_DynamicMapImpl<Object> set =
+        PrivateInternals_DynamicMapImpl<Object> map =
             (PrivateInternals_DynamicMapImpl<Object>) e.getValue();
 
         for (Binding<Object> b : bindings(src, type)) {
           if (b.getKey().getAnnotation() != null) {
-            handles.add(set.put(groupName, b.getKey(), b.getProvider()));
+            handles.add(map.put(pluginName, b.getKey(), b.getProvider()));
           }
         }
       }
@@ -174,8 +174,8 @@
         handles = new ArrayList<>(4);
         Injector parent = self.getParent();
         while (parent != null) {
-          handles.addAll(attachSets(self, dynamicSetsOf(parent)));
-          handles.addAll(attachMaps(self, "gerrit", dynamicMapsOf(parent)));
+          handles.addAll(attachSets(self, PluginName.GERRIT, dynamicSetsOf(parent)));
+          handles.addAll(attachMaps(self, PluginName.GERRIT, dynamicMapsOf(parent)));
           parent = parent.getParent();
         }
         if (handles.isEmpty()) {
diff --git a/java/com/google/gerrit/extensions/restapi/AuthException.java b/java/com/google/gerrit/extensions/restapi/AuthException.java
index 0b4f459..fe1744b 100644
--- a/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -14,10 +14,17 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+
 /** Caller cannot perform the request operation (HTTP 403 Forbidden). */
 public class AuthException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
+  private Optional<String> advice = Optional.empty();
+
   /** @param msg message to return to the client. */
   public AuthException(String msg) {
     super(msg);
@@ -30,4 +37,19 @@
   public AuthException(String msg, Throwable cause) {
     super(msg, cause);
   }
+
+  public void setAdvice(String advice) {
+    checkArgument(!Strings.isNullOrEmpty(advice));
+    this.advice = Optional.of(advice);
+  }
+
+  /**
+   * Advice that the user can follow to acquire authorization to perform the action.
+   *
+   * <p>This may be long-form text with newlines, and may be printed to a terminal, for example in
+   * the message stream in response to a push.
+   */
+  public Optional<String> getAdvice() {
+    return advice;
+  }
 }
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index c5163d1..929e182 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -54,6 +54,13 @@
     Truth.assertThat(trimMessages()).isEqualTo(Arrays.stream(expectedLines).collect(joining("\n")));
   }
 
+  public void containsMessages(String... expectedLines) {
+    checkArgument(expectedLines.length > 0, "use hasNoMessages()");
+    isNotNull();
+    Iterable<String> got = Splitter.on("\n").split(trimMessages());
+    Truth.assertThat(got).containsAllIn(expectedLines).inOrder();
+  }
+
   private String trimMessages() {
     return trimMessages(actual().getMessages());
   }
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index b8b0bc8..9d171d5 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -76,7 +76,7 @@
         // synchronized.
         if (!initializedFilters.contains(filter)) {
           filter.init(filterConfig);
-          initializedFilters.add(filter);
+          initializedFilters.add("gerrit", filter);
         }
       } else {
         ret = false;
@@ -89,7 +89,7 @@
       initializedFilters = new DynamicSet<>();
       for (AllRequestFilter filter : filtersToCleanUp) {
         if (filters.contains(filter)) {
-          initializedFilters.add(filter);
+          initializedFilters.add("gerrit", filter);
         } else {
           filter.destroy();
         }
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 3e71098..fae7c6a 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -16,6 +16,7 @@
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/http",
diff --git a/java/com/google/gerrit/httpd/GerritAuthModule.java b/java/com/google/gerrit/httpd/GerritAuthModule.java
new file mode 100644
index 0000000..c0ef207
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GerritAuthModule.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.servlet.ServletModule;
+import javax.servlet.Filter;
+
+/** Configures filter for authenticating REST requests. */
+public class GerritAuthModule extends ServletModule {
+  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GerritAuthModule(AuthConfig authConfig) {
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  protected void configureServlets() {
+    Class<? extends Filter> authFilter = retreiveAuthFilterFromConfig(authConfig);
+
+    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
+    filter("/a/*").through(authFilter);
+  }
+
+  static Class<? extends Filter> retreiveAuthFilterFromConfig(AuthConfig authConfig) {
+    Class<? extends Filter> authFilter;
+    if (authConfig.isTrustContainerAuth()) {
+      authFilter = ContainerAuthFilter.class;
+    } else {
+      authFilter =
+          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
+              ? ProjectOAuthFilter.class
+              : ProjectBasicAuthFilter.class;
+    }
+    return authFilter;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/GitOverHttpModule.java b/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 3f3737d..8400d60 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,20 +14,16 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+import static com.google.gerrit.httpd.GitOverHttpServlet.URL_REGEX;
 
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.servlet.ServletModule;
-import javax.servlet.Filter;
 
 /** Configures Git access over HTTP with authentication. */
 public class GitOverHttpModule extends ServletModule {
-  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
-
   private final AuthConfig authConfig;
   private final DownloadConfig downloadConfig;
 
@@ -39,28 +35,10 @@
 
   @Override
   protected void configureServlets() {
-    Class<? extends Filter> authFilter;
-    if (authConfig.isTrustContainerAuth()) {
-      authFilter = ContainerAuthFilter.class;
-    } else {
-      authFilter =
-          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
-              ? ProjectOAuthFilter.class
-              : ProjectBasicAuthFilter.class;
+    if (downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
+        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP)) {
+      filterRegex(URL_REGEX).through(GerritAuthModule.retreiveAuthFilterFromConfig(authConfig));
+      serveRegex(URL_REGEX).with(GitOverHttpServlet.class);
     }
-
-    if (isHttpEnabled()) {
-      String git = GitOverHttpServlet.URL_REGEX;
-      filterRegex(git).through(authFilter);
-      serveRegex(git).with(GitOverHttpServlet.class);
-    }
-
-    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
-    filter("/a/*").through(authFilter);
-  }
-
-  private boolean isHttpEnabled() {
-    return downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
-        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP);
   }
 }
diff --git a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
index 8b82c00..c41a7b9 100644
--- a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
+++ b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
@@ -59,7 +59,7 @@
       HttpServletResponse rsp = (HttpServletResponse) response;
       try {
         List<DocResult> result = searcher.doQuery(request.getParameter("q"));
-        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
+        RestApiServlet.replyJson(req, rsp, false, ImmutableListMultimap.of(), result);
       } catch (DocQueryException e) {
         logger.atSevere().withCause(e).log("Doc search failed");
         rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 8e380f5..cb476af 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GerritAuthModule;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -422,9 +423,10 @@
   private Injector createWebInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     if (sshInjector != null) {
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 9a24e47..74cadd3 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -456,8 +456,8 @@
       }
     }
 
-    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
-    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
+    cmds.sort(PluginEntry.COMPARATOR_BY_NAME);
+    docs.sort(PluginEntry.COMPARATOR_BY_NAME);
 
     StringBuilder md = new StringBuilder();
     md.append(String.format("# Plugin %s #\n", pluginName));
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
index 940a51b..2b390a9 100644
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -23,14 +23,15 @@
 import com.google.common.html.HtmlEscapers;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.util.http.CacheHeaders;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InterruptedIOException;
 import java.io.PrintWriter;
-import java.nio.file.Files;
-import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Properties;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -122,20 +123,19 @@
     }
   }
 
-  private Properties loadBuildProperties(Path propPath) throws IOException {
-    Properties properties = new Properties();
-    try (InputStream in = Files.newInputStream(propPath)) {
-      properties.load(in);
-    } catch (NoSuchFileException e) {
-      // Ignore; will be run from PATH, with a descriptive error if it fails.
-    }
-    return properties;
-  }
-
   private ProcessBuilder newBuildProcess(Label label) throws IOException {
-    Properties properties = loadBuildProperties(sourceRoot.resolve(".bazel_path"));
+    Properties properties = GerritLauncher.loadBuildProperties(sourceRoot.resolve(".bazel_path"));
     String bazel = firstNonNull(properties.getProperty("bazel"), "bazel");
-    ProcessBuilder proc = new ProcessBuilder(bazel, "build", label.fullName());
+    List<String> cmd = new ArrayList<>();
+    cmd.add(bazel);
+    cmd.add("build");
+    if (GerritLauncher.isJdk9OrLater()) {
+      String v = GerritLauncher.getJdkVersionPostJdk8();
+      cmd.add("--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
+      cmd.add("--java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
+    }
+    cmd.add(label.fullName());
+    ProcessBuilder proc = new ProcessBuilder(cmd);
     if (properties.containsKey("PATH")) {
       proc.environment().put("PATH", properties.getProperty("PATH"));
     }
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 2870cd0..172321d 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -56,8 +56,10 @@
 import org.kohsuke.args4j.CmdLineException;
 
 public class ParameterParser {
+  public static final String TRACE_PARAMETER = "trace";
+
   private static final ImmutableSet<String> RESERVED_KEYS =
-      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
+      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields", TRACE_PARAMETER);
 
   @AutoValue
   public abstract static class QueryParams {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
index 4af03a3..562687b 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.restapi;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.httpd.restapi.RestApiServlet.ViewData;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Counter2;
@@ -79,7 +80,8 @@
         break;
       }
     }
-    if (!Strings.isNullOrEmpty(viewData.pluginName) && !"gerrit".equals(viewData.pluginName)) {
+    if (!Strings.isNullOrEmpty(viewData.pluginName)
+        && !PluginName.GERRIT.equals(viewData.pluginName)) {
       impl = viewData.pluginName + '-' + impl;
     }
     return impl;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index a5c5a53..e0559f1 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
@@ -48,6 +49,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -68,6 +70,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -107,6 +110,8 @@
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -131,6 +136,7 @@
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
@@ -177,6 +183,8 @@
 
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
+  @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+
   // HTTP 422 Unprocessable Entity.
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
@@ -280,332 +288,347 @@
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      if (isCorsPreflight(req)) {
-        doCorsPreflight(req, res);
-        return;
-      }
+    try (TraceContext traceContext = enableTracing(req, res)) {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
+        logger.atFinest().log(
+            "Received REST request: %s %s (parameters: %s)",
+            req.getMethod(), req.getRequestURI(), getParameterNames(req));
+        logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
 
-      qp = ParameterParser.getQueryParams(req);
-      checkCors(req, res, qp.hasXdOverride());
-      if (qp.hasXdOverride()) {
-        req = applyXdOverrides(req, qp);
-      }
-      checkUserSession(req);
-
-      List<IdString> path = splitPath(req);
-      RestCollection<RestResource, RestResource> rc = members.get();
-      globals
-          .permissionBackend
-          .currentUser()
-          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-      viewData = new ViewData(null, null);
-
-      if (path.isEmpty()) {
-        if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(qp.params());
+        if (isCorsPreflight(req)) {
+          doCorsPreflight(req, res);
+          return;
         }
 
-        if (isRead(req)) {
-          viewData = new ViewData(null, rc.list());
-        } else if (isPost(req)) {
-          RestView<RestResource> restCollectionView =
-              rc.views().get("gerrit", "POST_ON_COLLECTION./");
-          if (restCollectionView != null) {
-            viewData = new ViewData(null, restCollectionView);
-          } else {
-            throw methodNotAllowed(req);
-          }
-        } else {
-          // DELETE on root collections is not supported
-          throw methodNotAllowed(req);
+        qp = ParameterParser.getQueryParams(req);
+        checkCors(req, res, qp.hasXdOverride());
+        if (qp.hasXdOverride()) {
+          req = applyXdOverrides(req, qp);
         }
-      } else {
-        IdString id = path.remove(0);
-        try {
-          rsrc = rc.parse(rsrc, id);
-          if (path.isEmpty()) {
-            checkPreconditions(req);
-          }
-        } catch (ResourceNotFoundException e) {
-          if (!path.isEmpty()) {
-            throw e;
-          }
+        checkUserSession(req);
 
-          if (isPost(req) || isPut(req)) {
-            RestView<RestResource> createView = rc.views().get("gerrit", "CREATE./");
-            if (createView != null) {
-              viewData = new ViewData(null, createView);
-              status = SC_CREATED;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else if (isDelete(req)) {
-            RestView<RestResource> deleteView = rc.views().get("gerrit", "DELETE_MISSING./");
-            if (deleteView != null) {
-              viewData = new ViewData(null, deleteView);
-              status = SC_NO_CONTENT;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rc, req.getMethod(), path);
-        }
-      }
-      checkRequiresCapability(viewData);
+        List<IdString> path = splitPath(req);
+        RestCollection<RestResource, RestResource> rc = members.get();
+        globals
+            .permissionBackend
+            .currentUser()
+            .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
-      while (viewData.view instanceof RestCollection<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollection<RestResource, RestResource> c =
-            (RestCollection<RestResource, RestResource>) viewData.view;
+        viewData = new ViewData(null, null);
 
         if (path.isEmpty()) {
+          if (rc instanceof NeedsParams) {
+            ((NeedsParams) rc).setParams(qp.params());
+          }
+
           if (isRead(req)) {
-            viewData = new ViewData(null, c.list());
+            viewData = new ViewData(null, rc.list());
           } else if (isPost(req)) {
             RestView<RestResource> restCollectionView =
-                c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
-            if (restCollectionView != null) {
-              viewData = new ViewData(null, restCollectionView);
-            } else {
-              throw methodNotAllowed(req);
-            }
-          } else if (isDelete(req)) {
-            RestView<RestResource> restCollectionView =
-                c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+                rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
             if (restCollectionView != null) {
               viewData = new ViewData(null, restCollectionView);
             } else {
               throw methodNotAllowed(req);
             }
           } else {
+            // DELETE on root collections is not supported
             throw methodNotAllowed(req);
           }
-          break;
-        }
-        IdString id = path.remove(0);
-        try {
-          rsrc = c.parse(rsrc, id);
-          checkPreconditions(req);
-          viewData = new ViewData(null, null);
-        } catch (ResourceNotFoundException e) {
-          if (!path.isEmpty()) {
-            throw e;
-          }
+        } else {
+          IdString id = path.remove(0);
+          try {
+            rsrc = rc.parse(rsrc, id);
+            if (path.isEmpty()) {
+              checkPreconditions(req);
+            }
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
 
-          if (isPost(req) || isPut(req)) {
-            RestView<RestResource> createView = c.views().get("gerrit", "CREATE./");
-            if (createView != null) {
-              viewData = new ViewData(null, createView);
-              status = SC_CREATED;
-              path.add(id);
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView =
+                  rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
             } else {
               throw e;
             }
-          } else if (isDelete(req)) {
-            RestView<RestResource> deleteView = c.views().get("gerrit", "DELETE_MISSING./");
-            if (deleteView != null) {
-              viewData = new ViewData(null, deleteView);
-              status = SC_NO_CONTENT;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else {
-            throw e;
           }
-        }
-        if (viewData.view == null) {
-          viewData = view(c, req.getMethod(), path);
+          if (viewData.view == null) {
+            viewData = view(rc, req.getMethod(), path);
+          }
         }
         checkRequiresCapability(viewData);
-      }
 
-      if (notModified(req, rsrc, viewData.view)) {
-        res.sendError(SC_NOT_MODIFIED);
-        return;
-      }
+        while (viewData.view instanceof RestCollection<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollection<RestResource, RestResource> c =
+              (RestCollection<RestResource, RestResource>) viewData.view;
 
-      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-        return;
-      }
-
-      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-      } else if (viewData.view instanceof RestModifyView<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestModifyView<RestResource, Object> m =
-            (RestModifyView<RestResource, Object>) viewData.view;
-
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+          if (path.isEmpty()) {
+            if (isRead(req)) {
+              viewData = new ViewData(null, c.list());
+            } else if (isPost(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else {
+              throw methodNotAllowed(req);
+            }
+            break;
           }
-        }
-      } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionCreateView<RestResource, RestResource, Object> m =
-            (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+          IdString id = path.remove(0);
+          try {
+            rsrc = c.parse(rsrc, id);
+            checkPreconditions(req);
+            viewData = new ViewData(null, null);
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
 
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, path.get(0), inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView =
+                  c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else {
+              throw e;
+            }
           }
-        }
-      } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-            (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, path.get(0), inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+          if (viewData.view == null) {
+            viewData = view(c, req.getMethod(), path);
           }
+          checkRequiresCapability(viewData);
         }
-      } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionModifyView<RestResource, RestResource, Object> m =
-            (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+        if (notModified(req, rsrc, viewData.view)) {
+          res.sendError(SC_NOT_MODIFIED);
+          return;
+        }
+
+        if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+          return;
+        }
+
+        if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+          result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+        } else if (viewData.view instanceof RestModifyView<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestModifyView<RestResource, Object> m =
+              (RestModifyView<RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
           }
-        }
-      } else {
-        throw new ResourceNotFoundException();
-      }
+        } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionCreateView<RestResource, RestResource, Object> m =
+              (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
-      if (result instanceof Response) {
-        @SuppressWarnings("rawtypes")
-        Response<?> r = (Response) result;
-        status = r.statusCode();
-        configureCaching(req, res, rsrc, viewData.view, r.caching());
-      } else if (result instanceof Response.Redirect) {
-        CacheHeaders.setNotCacheable(res);
-        res.sendRedirect(((Response.Redirect) result).location());
-        return;
-      } else if (result instanceof Response.Accepted) {
-        CacheHeaders.setNotCacheable(res);
-        res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-        return;
-      } else {
-        CacheHeaders.setNotCacheable(res);
-      }
-      res.setStatus(status);
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+              (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
 
-      if (result != Response.none()) {
-        result = Response.unwrap(result);
-        if (result instanceof BinaryResult) {
-          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionModifyView<RestResource, RestResource, Object> m =
+              (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
         } else {
-          responseBytes = replyJson(req, res, qp.config(), result);
+          throw new ResourceNotFoundException();
         }
-      }
-    } catch (MalformedJsonException | JsonParseException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (BadRequestException e) {
-      responseBytes =
-          replyError(
-              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
-    } catch (AuthException e) {
-      responseBytes =
-          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
-    } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-    } catch (ResourceNotFoundException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
-    } catch (MethodNotAllowedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_METHOD_NOT_ALLOWED,
-              messageOr(e, "Method Not Allowed"),
-              e.caching(),
-              e);
-    } catch (ResourceConflictException e) {
-      responseBytes =
-          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
-    } catch (PreconditionFailedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_PRECONDITION_FAILED,
-              messageOr(e, "Precondition Failed"),
-              e.caching(),
-              e);
-    } catch (UnprocessableEntityException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_UNPROCESSABLE_ENTITY,
-              messageOr(e, "Unprocessable Entity"),
-              e.caching(),
-              e);
-    } catch (NotImplementedException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-    } catch (UpdateException e) {
-      Throwable t = e.getCause();
-      if (t instanceof LockFailureException) {
+
+        if (result instanceof Response) {
+          @SuppressWarnings("rawtypes")
+          Response<?> r = (Response) result;
+          status = r.statusCode();
+          configureCaching(req, res, rsrc, viewData.view, r.caching());
+        } else if (result instanceof Response.Redirect) {
+          CacheHeaders.setNotCacheable(res);
+          String location = ((Response.Redirect) result).location();
+          res.sendRedirect(location);
+          logger.atFinest().log("REST call redirected to: %s", location);
+          return;
+        } else if (result instanceof Response.Accepted) {
+          CacheHeaders.setNotCacheable(res);
+          res.setStatus(SC_ACCEPTED);
+          res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+          logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
+          return;
+        } else {
+          CacheHeaders.setNotCacheable(res);
+        }
+        res.setStatus(status);
+        logger.atFinest().log("REST call succeeded: %d", status);
+
+        if (result != Response.none()) {
+          result = Response.unwrap(result);
+          if (result instanceof BinaryResult) {
+            responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+          } else {
+            responseBytes = replyJson(req, res, false, qp.config(), result);
+          }
+        }
+      } catch (MalformedJsonException | JsonParseException e) {
         responseBytes =
-            replyError(req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
-      } else {
+            replyError(
+                req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+      } catch (BadRequestException e) {
+        responseBytes =
+            replyError(
+                req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+      } catch (AuthException e) {
+        responseBytes =
+            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+      } catch (AmbiguousViewException e) {
+        responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      } catch (ResourceNotFoundException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+      } catch (MethodNotAllowedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_METHOD_NOT_ALLOWED,
+                messageOr(e, "Method Not Allowed"),
+                e.caching(),
+                e);
+      } catch (ResourceConflictException e) {
+        responseBytes =
+            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+      } catch (PreconditionFailedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_PRECONDITION_FAILED,
+                messageOr(e, "Precondition Failed"),
+                e.caching(),
+                e);
+      } catch (UnprocessableEntityException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_UNPROCESSABLE_ENTITY,
+                messageOr(e, "Unprocessable Entity"),
+                e.caching(),
+                e);
+      } catch (NotImplementedException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
+      } catch (UpdateException e) {
+        Throwable t = e.getCause();
+        if (t instanceof LockFailureException) {
+          responseBytes =
+              replyError(
+                  req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
+        } else {
+          status = SC_INTERNAL_SERVER_ERROR;
+          responseBytes = handleException(e, req, res);
+        }
+      } catch (Exception e) {
         status = SC_INTERNAL_SERVER_ERROR;
         responseBytes = handleException(e, req, res);
+      } finally {
+        String metric =
+            viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+        globals.metrics.count.increment(metric);
+        if (status >= SC_BAD_REQUEST) {
+          globals.metrics.errorCount.increment(metric, status);
+        }
+        if (responseBytes != -1) {
+          globals.metrics.responseBytes.record(metric, responseBytes);
+        }
+        globals.metrics.serverLatency.record(
+            metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+        globals.auditService.dispatch(
+            new ExtendedHttpAuditEvent(
+                globals.webSession.get().getSessionId(),
+                globals.currentUser.get(),
+                req,
+                auditStartTs,
+                qp != null ? qp.params() : ImmutableListMultimap.of(),
+                inputRequestBody,
+                status,
+                result,
+                rsrc,
+                viewData == null ? null : viewData.view));
       }
-    } catch (Exception e) {
-      status = SC_INTERNAL_SERVER_ERROR;
-      responseBytes = handleException(e, req, res);
-    } finally {
-      String metric =
-          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
-      globals.metrics.count.increment(metric);
-      if (status >= SC_BAD_REQUEST) {
-        globals.metrics.errorCount.increment(metric, status);
-      }
-      if (responseBytes != -1) {
-        globals.metrics.responseBytes.record(metric, responseBytes);
-      }
-      globals.metrics.serverLatency.record(
-          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(
-          new ExtendedHttpAuditEvent(
-              globals.webSession.get().getSessionId(),
-              globals.currentUser.get(),
-              req,
-              auditStartTs,
-              qp != null ? qp.params() : ImmutableListMultimap.of(),
-              inputRequestBody,
-              status,
-              result,
-              rsrc,
-              viewData == null ? null : viewData.view));
     }
   }
 
@@ -968,9 +991,22 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
+  /**
+   * Sets a JSON reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param config config parameters for the JSON formatting
+   * @param result the object that should be formatted as JSON
+   * @return the length of the response
+   * @throws IOException
+   */
   public static long replyJson(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
+      boolean allowTracing,
       ListMultimap<String, String> config,
       Object result)
       throws IOException {
@@ -985,6 +1021,21 @@
     }
     w.write('\n');
     w.flush();
+
+    if (allowTracing) {
+      logger.atFinest().log(
+          "JSON response body:\n%s",
+          lazy(
+              () -> {
+                try {
+                  ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
+                  buf.writeTo(debugOut, null);
+                  return debugOut.toString(UTF_8.name());
+                } catch (IOException e) {
+                  return "<JSON formatting failed>";
+                }
+              }));
+    }
     return replyBinaryResult(
         req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
@@ -1197,14 +1248,14 @@
     }
 
     String name = method + "." + p.get(0);
-    RestView<RestResource> core = views.get("gerrit", name);
+    RestView<RestResource> core = views.get(PluginName.GERRIT, name);
     if (core != null) {
-      return new ViewData("gerrit", core);
+      return new ViewData(PluginName.GERRIT, core);
     }
 
-    core = views.get("gerrit", "GET." + p.get(0));
+    core = views.get(PluginName.GERRIT, "GET." + p.get(0));
     if (core != null) {
-      return new ViewData("gerrit", core);
+      return new ViewData(PluginName.GERRIT, core);
     }
 
     Map<String, RestView<RestResource>> r = new TreeMap<>();
@@ -1265,6 +1316,51 @@
     }
   }
 
+  private List<String> getParameterNames(HttpServletRequest req) {
+    List<String> parameterNames = new ArrayList<>(req.getParameterMap().keySet());
+    Collections.sort(parameterNames);
+    return parameterNames;
+  }
+
+  private TraceContext enableTracing(HttpServletRequest req, HttpServletResponse res) {
+    // There are 2 ways to enable tracing for REST calls:
+    // 1. by using the 'trace' or 'trace=<trace-id>' request parameter
+    // 2. by setting the 'X-Gerrit-Trace:' or 'X-Gerrit-Trace:<trace-id>' header
+    String traceValueFromHeader = req.getHeader(X_GERRIT_TRACE);
+    String traceValueFromRequestParam = req.getParameter(ParameterParser.TRACE_PARAMETER);
+    boolean doTrace = traceValueFromHeader != null || traceValueFromRequestParam != null;
+
+    // Check whether no trace ID, one trace ID or 2 different trace IDs have been specified.
+    String traceId1;
+    String traceId2;
+    if (!Strings.isNullOrEmpty(traceValueFromHeader)) {
+      traceId1 = traceValueFromHeader;
+      if (!Strings.isNullOrEmpty(traceValueFromRequestParam)
+          && !traceValueFromHeader.equals(traceValueFromRequestParam)) {
+        traceId2 = traceValueFromRequestParam;
+      } else {
+        traceId2 = null;
+      }
+    } else {
+      traceId1 = Strings.emptyToNull(traceValueFromRequestParam);
+      traceId2 = null;
+    }
+
+    // Use the first trace ID to start tracing. If this trace ID is null, a trace ID will be
+    // generated.
+    TraceContext traceContext =
+        TraceContext.newTrace(
+            doTrace,
+            traceId1,
+            (tagName, traceId) -> res.setHeader(X_GERRIT_TRACE, traceId.toString()));
+    // If a second trace ID was specified, add a tag for it as well.
+    if (traceId2 != null) {
+      traceContext.addTag(RequestId.Type.TRACE_ID, traceId2);
+      res.addHeader(X_GERRIT_TRACE, traceId2);
+    }
+    return traceContext;
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1296,10 +1392,15 @@
 
   private void checkRequiresCapability(ViewData d)
       throws AuthException, PermissionBackendException {
-    globals
-        .permissionBackend
-        .currentUser()
-        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    try {
+      globals.permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException e) {
+      // Skiping
+      globals
+          .permissionBackend
+          .currentUser()
+          .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    }
   }
 
   private static long handleException(
@@ -1341,17 +1442,34 @@
     configureCaching(req, res, null, null, c);
     checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
     res.setStatus(statusCode);
-    return replyText(req, res, msg);
+    logger.atFinest().log("REST call failed: %d", statusCode);
+    return replyText(req, res, true, msg);
   }
 
-  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
+  /**
+   * Sets a text reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param text the text reply
+   * @return the length of the response
+   * @throws IOException
+   */
+  static long replyText(
+      @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
       throws IOException {
     if ((req == null || isRead(req)) && isMaybeHTML(text)) {
-      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
+      return replyJson(
+          req, res, allowTracing, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
     if (!text.endsWith("\n")) {
       text += "\n";
     }
+    if (allowTracing) {
+      logger.atFinest().log("Text response body:\n%s", text);
+    }
     return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 6604ca1..d5517e1 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -12,19 +12,6 @@
 )
 
 java_library(
-    name = "query_parser",
-    srcs = ["//antlr3:query"],
-    visibility = [
-        "//javatests/com/google/gerrit/index:__pkg__",
-        "//plugins:__pkg__",
-    ],
-    deps = [
-        ":query_exception",
-        "//lib/antlr:java-runtime",
-    ],
-)
-
-java_library(
     name = "index",
     srcs = glob(
         ["**/*.java"],
@@ -33,7 +20,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":query_exception",
-        ":query_parser",
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/metrics",
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index 24b7a69..9c56396 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import java.io.OutputStream;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.RejectedExecutionException;
@@ -64,7 +66,7 @@
 
   protected int totalWork = -1;
   protected OutputStream progressOut = NullOutputStream.INSTANCE;
-  protected PrintWriter verboseWriter = new PrintWriter(NullOutputStream.INSTANCE);
+  protected PrintWriter verboseWriter = newPrintWriter(NullOutputStream.INSTANCE);
 
   public void setTotalWork(int num) {
     totalWork = num;
@@ -75,7 +77,7 @@
   }
 
   public void setVerboseOut(OutputStream out) {
-    verboseWriter = new PrintWriter(checkNotNull(out));
+    verboseWriter = newPrintWriter(checkNotNull(out));
   }
 
   public abstract Result indexAll(I index);
@@ -86,6 +88,10 @@
         new ErrorListener(future, desc, progress, ok), MoreExecutors.directExecutor());
   }
 
+  protected PrintWriter newPrintWriter(OutputStream out) {
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+  }
+
   private static class ErrorListener implements Runnable {
     private final ListenableFuture<?> future;
     private final String desc;
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index 7365660..fb029ac 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
@@ -53,4 +54,11 @@
   public ImmutableList<String> getParentNames() {
     return tree().stream().skip(1).map(p -> p.getProject().getName()).collect(toImmutableList());
   }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(project.getName());
+    return h.toString();
+  }
 }
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index e2605f4..d1e1c30 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index.query;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
@@ -26,7 +27,6 @@
 import com.google.gwtorm.server.ResultSet;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
@@ -175,10 +175,8 @@
     return cardinality;
   }
 
-  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
-    List<Predicate<T>> r = new ArrayList<>(that);
-    Collections.sort(r, this);
-    return r;
+  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    return that.stream().sorted(this).collect(toImmutableList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index ca74a52..53c92c9 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -105,6 +105,18 @@
     return getChildren().get(i);
   }
 
+  /** Get the number of leaf terms in this predicate. */
+  public int getLeafCount() {
+    int leafCount = 0;
+    for (Predicate<?> childPredicate : getChildren()) {
+      if (childPredicate instanceof IndexPredicate) {
+        leafCount++;
+      }
+      leafCount += childPredicate.getLeafCount();
+    }
+    return leafCount;
+  }
+
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 27ed72f..1a42ebd 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -22,11 +22,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.Description;
@@ -52,6 +54,8 @@
  * holding on to a single instance.
  */
 public abstract class QueryProcessor<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected static class Metrics {
     final Timer1<String> executionTime;
 
@@ -206,6 +210,7 @@
       List<Integer> limits = new ArrayList<>(cnt);
       List<Predicate<T>> predicates = new ArrayList<>(cnt);
       List<DataSource<T>> sources = new ArrayList<>(cnt);
+      int queryCount = 0;
       for (Predicate<T> q : queries) {
         int limit = getEffectiveLimit(q);
         limits.add(limit);
@@ -224,11 +229,17 @@
         // max for this user. The only way to see if there are more entities is to
         // ask for one more result from the query.
         QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
+        logger.atFine().log("Query options: " + opts);
         Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
         predicates.add(pred);
+        logger.atFine().log(
+            "%s index query[%d]:\n%s",
+            schemaDef.getName(),
+            queryCount++,
+            pred instanceof IndexedQuery ? pred.getChild(0) : pred);
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
@@ -243,12 +254,14 @@
 
       out = new ArrayList<>(cnt);
       for (int i = 0; i < cnt; i++) {
+        List<T> matchesList = matches.get(i).toList();
+        logger.atFine().log("Matches[%d]:\n%s", i, matchesList);
         out.add(
             QueryResult.create(
                 queryStrings != null ? queryStrings.get(i) : null,
                 predicates.get(i),
                 limits.get(i),
-                matches.get(i).toList()));
+                matchesList));
       }
 
       // Only measure successful queries that actually touched the index.
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 13dad0e..0d26fe7 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -34,6 +34,7 @@
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.CodeSource;
@@ -44,6 +45,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import java.util.Scanner;
 import java.util.SortedMap;
 import java.util.TreeMap;
@@ -644,6 +646,25 @@
     return resolveInSourceRoot("eclipse-out");
   }
 
+  public static boolean isJdk9OrLater() {
+    return Double.parseDouble(System.getProperty("java.class.version")) >= 53.0;
+  }
+
+  public static String getJdkVersionPostJdk8() {
+    // 9.0.4 => 9
+    return System.getProperty("java.version").substring(0, 1);
+  }
+
+  public static Properties loadBuildProperties(Path propPath) throws IOException {
+    Properties properties = new Properties();
+    try (InputStream in = Files.newInputStream(propPath)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
   static final String SOURCE_ROOT_RESOURCE = "/com/google/gerrit/launcher/workspace-root.txt";
 
   /**
@@ -708,14 +729,36 @@
     return ret;
   }
 
-  private static ClassLoader useDevClasspath() throws MalformedURLException, FileNotFoundException {
+  private static ClassLoader useDevClasspath() throws IOException {
     Path out = getDeveloperEclipseOut();
     List<URL> dirs = new ArrayList<>();
     dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
-    for (URL u : ((URLClassLoader) cl).getURLs()) {
-      if (includeJar(u)) {
-        dirs.add(u);
+
+    if (isJdk9OrLater()) {
+      Path rootPath = resolveInSourceRoot(".").normalize();
+
+      Properties properties = loadBuildProperties(rootPath.resolve(".bazel_path"));
+      Path outputBase = Paths.get(properties.getProperty("output_base"));
+
+      Path runtimeClasspath =
+          rootPath.resolve("bazel-bin/tools/eclipse/main_classpath_collect.runtime_classpath");
+      for (String f : Files.readAllLines(runtimeClasspath, UTF_8)) {
+        URL url;
+        if (f.startsWith("external")) {
+          url = outputBase.resolve(f).toUri().toURL();
+        } else {
+          url = rootPath.resolve(f).toUri().toURL();
+        }
+        if (includeJar(url)) {
+          dirs.add(url);
+        }
+      }
+    } else {
+      for (URL u : ((URLClassLoader) cl).getURLs()) {
+        if (includeJar(u)) {
+          dirs.add(u);
+        }
       }
     }
     return URLClassLoader.newInstance(
@@ -724,7 +767,9 @@
 
   private static boolean includeJar(URL u) {
     String path = u.getPath();
-    return path.endsWith(".jar") && !path.endsWith("-src.jar");
+    return path.endsWith(".jar")
+        && !path.endsWith("-src.jar")
+        && !path.contains("/com/google/gerrit");
   }
 
   private GerritLauncher() {}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 3871ced..dc293cd 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -39,6 +39,8 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import java.io.IOException;
@@ -54,6 +56,7 @@
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -99,7 +102,7 @@
   private final ReferenceManager<IndexSearcher> searcherManager;
   private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
   private final Set<NrtFuture> notDoneNrtFutures;
-  private ScheduledThreadPoolExecutor autoCommitExecutor;
+  private ScheduledExecutorService autoCommitExecutor;
 
   AbstractLuceneIndex(
       Schema<V> schema,
@@ -128,12 +131,13 @@
       delegateWriter = autoCommitWriter;
 
       autoCommitExecutor =
-          new ScheduledThreadPoolExecutor(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat(index + " Commit-%d")
-                  .setDaemon(true)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              new ScheduledThreadPoolExecutor(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat(index + " Commit-%d")
+                      .setDaemon(true)
+                      .build()));
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
@@ -168,12 +172,13 @@
 
     writerThread =
         MoreExecutors.listeningDecorator(
-            Executors.newFixedThreadPool(
-                1,
-                new ThreadFactoryBuilder()
-                    .setNameFormat(index + " Write-%d")
-                    .setDaemon(true)
-                    .build()));
+            new LoggingContextAwareExecutorService(
+                Executors.newFixedThreadPool(
+                    1,
+                    new ThreadFactoryBuilder()
+                        .setNameFormat(index + " Write-%d")
+                        .setDaemon(true)
+                        .build())));
 
     reopenThread =
         new ControlledRealTimeReopenThread<>(
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 6cb7751..9c6ba74 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -33,6 +33,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index ea408e2..d3c030d 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -68,7 +68,7 @@
 
   @Override
   public Timer0 newTimer(String name, Description desc) {
-    return new Timer0() {
+    return new Timer0(name) {
       @Override
       public void record(long value, TimeUnit unit) {}
 
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name) {
       @Override
       public void record(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>() {
+    return new Timer2<F1, F2>(name) {
       @Override
       public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>() {
+    return new Timer3<F1, F2, F3>(name) {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 55d1ddf..225b76f 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -31,6 +32,8 @@
  */
 public abstract class Timer0 implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer0 timer;
 
     Context(Timer0 timer) {
@@ -39,10 +42,17 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log("%s took %dms", timer.name, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer0(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index f623841..0db0353 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -33,6 +34,8 @@
  */
 public abstract class Timer1<F1> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer1<Object> timer;
     private final Object field1;
 
@@ -44,10 +47,18 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s) took %dms", timer.name, field1, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer1(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index b03ff83..cfdfb7a 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -34,6 +35,8 @@
  */
 public abstract class Timer2<F1, F2> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer2<Object, Object> timer;
     private final Object field1;
     private final Object field2;
@@ -47,10 +50,19 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s, %s) took %dms",
+          timer.name, field1, field2, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, field2, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer2(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 91af42c..1711445 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.concurrent.TimeUnit;
 
@@ -35,6 +36,8 @@
  */
 public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
   public static class Context extends TimerContext {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
     private final Timer3<Object, Object, Object> timer;
     private final Object field1;
     private final Object field2;
@@ -50,10 +53,19 @@
 
     @Override
     public void record(long elapsed) {
+      logger.atFinest().log(
+          "%s (%s, %s, %s) took %dms",
+          timer.name, field1, field2, field3, TimeUnit.NANOSECONDS.toMillis(elapsed));
       timer.record(field1, field2, field3, elapsed, NANOSECONDS);
     }
   }
 
+  protected final String name;
+
+  public Timer3(String name) {
+    this.name = name;
+  }
+
   /**
    * Begin a timer for the current block, value will be recorded when closed.
    *
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
index 3b19a62..a7ffe07 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -26,7 +26,7 @@
 /** Abstract timer broken down into buckets by {@link Field} values. */
 abstract class BucketedTimer implements BucketedMetric {
   private final DropWizardMetricMaker metrics;
-  private final String name;
+  protected final String name;
   private final Description.FieldOrdering ordering;
   protected final Field<?>[] fields;
   protected final TimerImpl total;
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index fc53ee7..ead718f 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -393,11 +393,10 @@
   }
 
   class TimerImpl extends Timer0 {
-    private final String name;
     final com.codahale.metrics.Timer metric;
 
     private TimerImpl(String name, com.codahale.metrics.Timer metric) {
-      this.name = name;
+      super(name);
       this.metric = metric;
     }
 
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index fe6f70e..fc4ba3f 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -27,7 +27,7 @@
   }
 
   Timer1<F1> timer() {
-    return new Timer1<F1>() {
+    return new Timer1<F1>(name) {
       @Override
       public void record(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index 43cc290..d04a65e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -30,7 +30,7 @@
   }
 
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>() {
+    return new Timer2<F1, F2>(name) {
       @Override
       public void record(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -45,7 +45,7 @@
   }
 
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>() {
+    return new Timer3<F1, F2, F3>(name) {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 517787c..1a9af55 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GerritAuthModule;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -575,10 +576,11 @@
       modules.add(new ProjectQoSFilter.Module());
     }
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
     modules.add(H2CacheBasedWebSession.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     modules.add(new HttpPluginModule());
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
index 07da3f7..61d7ed9 100644
--- a/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
@@ -39,6 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
@@ -141,7 +143,7 @@
           migrator.migrate();
         }
       }
-      try (PrintWriter w = new PrintWriter(System.out, true)) {
+      try (PrintWriter w = new PrintWriter(new OutputStreamWriter(System.out, UTF_8), true)) {
         gcAllUsers.run(w);
       }
     } finally {
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 707802e..6336c93 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -39,13 +39,13 @@
 public class ExternalIdsOnInit {
   private final InitFlags flags;
   private final SitePaths site;
-  private final String allUsers;
+  private final AllUsersName allUsers;
 
   @Inject
   public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
     this.flags = flags;
     this.site = site;
-    this.allUsers = allUsers.get();
+    this.allUsers = new AllUsersName(allUsers.get());
   }
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
@@ -53,11 +53,10 @@
     File path = getPath();
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersRepo);
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, allUsersRepo);
         extIdNotes.insert(extIds);
         try (MetaDataUpdate metaDataUpdate =
-            new MetaDataUpdate(
-                GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), allUsersRepo)) {
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, allUsersRepo)) {
           PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
           metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
           metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
@@ -73,6 +72,6 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    return FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 8fc9119..8e06aa1 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -64,13 +64,13 @@
 
   private final InitFlags flags;
   private final SitePaths site;
-  private final String allUsers;
+  private final AllUsersName allUsers;
 
   @Inject
   public GroupsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
     this.flags = flags;
     this.site = site;
-    this.allUsers = allUsers.get();
+    this.allUsers = new AllUsersName(allUsers.get());
   }
 
   /**
@@ -90,7 +90,7 @@
     if (allUsersRepoPath != null) {
       try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
         AccountGroup.UUID groupUuid = groupReference.getUUID();
-        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsers, allUsersRepo, groupUuid);
         return groupConfig
             .getLoadedGroup()
             .orElseThrow(() -> new NoSuchGroupException(groupReference.getUUID()));
@@ -145,7 +145,7 @@
   private void addGroupMemberInNoteDb(
       Repository repository, AccountGroup.UUID groupUuid, Account account)
       throws IOException, ConfigInvalidException, NoSuchGroupException {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsers, repository, groupUuid);
     InternalGroup group =
         groupConfig.getLoadedGroup().orElseThrow(() -> new NoSuchGroupException(groupUuid));
 
@@ -160,7 +160,7 @@
   private File getPathToAllUsersRepository() {
     Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
     checkArgument(basePath != null, "gerrit.basePath must be configured");
-    return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+    return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 
   private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
@@ -186,7 +186,7 @@
 
   private MetaDataUpdate createMetaDataUpdate(Repository repository, PersonIdent personIdent) {
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), repository);
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repository);
     metaDataUpdate.getCommitBuilder().setAuthor(personIdent);
     metaDataUpdate.getCommitBuilder().setCommitter(personIdent);
     return metaDataUpdate;
diff --git a/java/com/google/gerrit/pgm/init/InitLogging.java b/java/com/google/gerrit/pgm/init/InitLogging.java
new file mode 100644
index 0000000..52d0d2f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitLogging.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class InitLogging implements InitStep {
+  private static final String CONTAINER = "container";
+  private static final String JAVA_OPTIONS = "javaOptions";
+  private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
+
+  private final Section container;
+
+  @Inject
+  public InitLogging(Section.Factory sections) {
+    this.container = sections.get(CONTAINER, null);
+  }
+
+  @Override
+  public void run() throws Exception {
+    List<String> javaOptions = new ArrayList<>(Arrays.asList(container.getList(JAVA_OPTIONS)));
+    if (!isSet(javaOptions, FLOGGER_BACKEND_PROPERTY)) {
+      javaOptions.add(
+          getJavaOption(
+              FLOGGER_BACKEND_PROPERTY,
+              "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance"));
+    }
+    if (!isSet(javaOptions, FLOGGER_LOGGING_CONTEXT)) {
+      javaOptions.add(
+          getJavaOption(
+              FLOGGER_LOGGING_CONTEXT,
+              "com.google.gerrit.server.logging.LoggingContext#getInstance"));
+    }
+    container.setList(JAVA_OPTIONS, javaOptions);
+  }
+
+  private static boolean isSet(List<String> javaOptions, String javaOptionName) {
+    return javaOptions
+        .stream()
+        .anyMatch(
+            o ->
+                o.startsWith("-D" + javaOptionName + "=")
+                    || o.startsWith("\"-D" + javaOptionName + "="));
+  }
+
+  private static String getJavaOption(String javaOptionName, String value) {
+    return String.format("-D%s=%s", javaOptionName, value);
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index 65cf355..f677ceb 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -49,6 +49,7 @@
     if (initDb) {
       step().to(InitDatabase.class);
     }
+    step().to(InitLogging.class);
     step().to(InitIndex.class);
     step().to(InitAuth.class);
     step().to(InitAdminUser.class);
diff --git a/java/com/google/gerrit/pgm/init/InitPlugins.java b/java/com/google/gerrit/pgm/init/InitPlugins.java
index 385d20c..e43114c 100644
--- a/java/com/google/gerrit/pgm/init/InitPlugins.java
+++ b/java/com/google/gerrit/pgm/init/InitPlugins.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.pgm.init;
 
-import com.google.common.collect.FluentIterable;
+import static java.util.Comparator.comparing;
+
 import com.google.gerrit.common.PluginData;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -25,12 +26,10 @@
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -58,25 +57,16 @@
       throws IOException {
     final List<PluginData> result = new ArrayList<>();
     pluginsDistribution.foreach(
-        new PluginsDistribution.Processor() {
-          @Override
-          public void process(String pluginName, InputStream in) throws IOException {
-            Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
-            String pluginVersion = getVersion(tmpPlugin);
-            if (deleteTempPluginFile) {
-              Files.delete(tmpPlugin);
-            }
-            result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
+        (pluginName, in) -> {
+          Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
+          String pluginVersion = getVersion(tmpPlugin);
+          if (deleteTempPluginFile) {
+            Files.delete(tmpPlugin);
           }
+          result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
         });
-    return FluentIterable.from(result)
-        .toSortedList(
-            new Comparator<PluginData>() {
-              @Override
-              public int compare(PluginData a, PluginData b) {
-                return a.name.compareTo(b.name);
-              }
-            });
+    result.sort(comparing(p -> p.name));
+    return result;
   }
 
   private final ConsoleUI ui;
diff --git a/java/com/google/gerrit/pgm/init/InitSshd.java b/java/com/google/gerrit/pgm/init/InitSshd.java
index 0cc30f8..68bdefc 100644
--- a/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -103,7 +103,7 @@
                 "-q" /* quiet */,
                 "-t",
                 "rsa",
-                "-P",
+                "-N",
                 emptyPassphraseArg,
                 "-C",
                 comment,
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index 009e989..baf37b6 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -23,6 +23,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
+import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 
@@ -59,6 +60,10 @@
     return flags.cfg.getString(section, subsection, name);
   }
 
+  public String[] getList(String name) {
+    return flags.cfg.getStringList(section, subsection, name);
+  }
+
   public void set(String name, String value) {
     final ArrayList<String> all = new ArrayList<>();
     all.addAll(Arrays.asList(flags.cfg.getStringList(section, subsection, name)));
@@ -79,6 +84,10 @@
     }
   }
 
+  public void setList(String name, List<String> values) {
+    flags.cfg.setStringList(section, subsection, name, values);
+  }
+
   public <T extends Enum<?>> void set(String name, T value) {
     if (value != null) {
       set(name, value.name());
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index e3b95ee..738cafd 100644
--- a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -57,7 +58,7 @@
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path)) {
-        load(repo);
+        load(new Project.NameKey(project), repo);
       }
     }
     return this;
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 8d77ed8..683a205 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -188,6 +189,7 @@
     factory(SubmitRuleEvaluator.Factory.class);
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
 
     bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
     bind(EventUtil.class).toProvider(Providers.<EventUtil>of(null));
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 057496f..1338efb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -64,6 +64,8 @@
 import org.kohsuke.args4j.Option;
 
 public abstract class SiteProgram extends AbstractProgram {
+  private static final String CONNECTION_ERROR = "Cannot connect to SQL database";
+
   @Option(
       name = "--site-path",
       aliases = {"-d"},
@@ -106,14 +108,13 @@
 
   /** @return provides database connectivity and site path. */
   protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
-    Path sitePath = getSitePath();
     List<Module> modules = new ArrayList<>();
 
     Module sitePathModule =
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
             bind(String.class)
                 .annotatedWith(SecureStoreClassName.class)
                 .toProvider(Providers.of(getConfiguredSecureStoreClass()));
@@ -198,16 +199,16 @@
       Throwable why = first.getCause();
 
       if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
       if (why instanceof OrmException
           && why.getCause() != null
           && "Unable to determine driver URL".equals(why.getMessage())) {
         why = why.getCause();
         if (isCannotCreatePoolException(why)) {
-          throw die("Cannot connect to SQL database", why.getCause());
+          throw die(CONNECTION_ERROR, why.getCause());
         }
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
 
       StringBuilder buf = new StringBuilder();
@@ -259,8 +260,9 @@
     for (Binding<DataSourceType> binding : dsTypeBindings) {
       Annotation annotation = binding.getKey().getAnnotation();
       if (annotation instanceof Named) {
-        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
-          return ((Named) annotation).value();
+        Named named = (Named) annotation;
+        if (named.value().toLowerCase().contains(dbProductName)) {
+          return named.value();
         }
       }
     }
diff --git a/java/com/google/gerrit/proto/ProtoGen.java b/java/com/google/gerrit/proto/ProtoGen.java
index 1c55a05..4a1598b 100644
--- a/java/com/google/gerrit/proto/ProtoGen.java
+++ b/java/com/google/gerrit/proto/ProtoGen.java
@@ -34,12 +34,11 @@
 
 public class ProtoGen {
   @Option(
-    name = "--output",
-    aliases = {"-o"},
-    required = true,
-    metaVar = "FILE",
-    usage = "File to write .proto into"
-  )
+      name = "--output",
+      aliases = {"-o"},
+      required = true,
+      metaVar = "FILE",
+      usage = "File to write .proto into")
   private File file;
 
   public static void main(String[] argv) throws Exception {
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
index 201315e..8d4de05 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/reviewdb/client/Change.java
@@ -253,7 +253,10 @@
     }
   }
 
-  /** Globally unique identification of this change. */
+  /**
+   * Globally unique identification of this change. This generally takes the form of a string
+   * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
+   */
   public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index e99c686..e5bc480 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -40,16 +40,43 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
         "//java/org/apache/commons/net",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
+        "//lib:autolink",
         "//lib:automaton",
         "//lib:blame-cache",
-        "//lib:grappa",
+        "//lib:flexmark",
+        "//lib:flexmark-ext-abbreviation",
+        "//lib:flexmark-ext-anchorlink",
+        "//lib:flexmark-ext-autolink",
+        "//lib:flexmark-ext-definition",
+        "//lib:flexmark-ext-emoji",
+        "//lib:flexmark-ext-escaped-character",
+        "//lib:flexmark-ext-footnotes",
+        "//lib:flexmark-ext-gfm-issues",
+        "//lib:flexmark-ext-gfm-strikethrough",
+        "//lib:flexmark-ext-gfm-tables",
+        "//lib:flexmark-ext-gfm-tasklist",
+        "//lib:flexmark-ext-gfm-users",
+        "//lib:flexmark-ext-ins",
+        "//lib:flexmark-ext-jekyll-front-matter",
+        "//lib:flexmark-ext-superscript",
+        "//lib:flexmark-ext-tables",
+        "//lib:flexmark-ext-toc",
+        "//lib:flexmark-ext-typographic",
+        "//lib:flexmark-ext-wikilink",
+        "//lib:flexmark-ext-yaml-front-matter",
+        "//lib:flexmark-formatter",
+        "//lib:flexmark-html-parser",
+        "//lib:flexmark-profile-pegdown",
+        "//lib:flexmark-util",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -58,7 +85,6 @@
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
-        "//lib:pegdown",
         "//lib:protobuf",
         "//lib:servlet-api-3_1",
         "//lib:soy",
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index d90f5d0..571f322 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -37,17 +37,9 @@
   private static final Random UUID_RANDOM = new SecureRandom();
   private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
-  private static final int SUBJECT_MAX_LENGTH = 80;
-  private static final String SUBJECT_CROP_APPENDIX = "...";
-  private static final int SUBJECT_CROP_RANGE = 10;
-
   public static final Ordering<PatchSet> PS_ID_ORDER =
       Ordering.from(comparingInt(PatchSet::getPatchSetId));
 
-  public static String formatChangeUrl(String canonicalWebUrl, Change change) {
-    return canonicalWebUrl + "c/" + change.getProject().get() + "/+/" + change.getChangeId();
-  }
-
   /** @return a new unique identifier for change message entities. */
   public static String messageUuid() {
     byte[] buf = new byte[8];
@@ -123,21 +115,6 @@
         id);
   }
 
-  public static String cropSubject(String subject) {
-    if (subject.length() > SUBJECT_MAX_LENGTH) {
-      int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
-      for (int cropPosition = maxLength;
-          cropPosition > maxLength - SUBJECT_CROP_RANGE;
-          cropPosition--) {
-        if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
-          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
-        }
-      }
-      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
-    }
-    return subject;
-  }
-
   public static String status(Change c) {
     return c != null ? c.getStatus().name().toLowerCase() : "deleted";
   }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 18d9b3d..99dfbbb 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -502,7 +502,7 @@
   }
 
   private static <T extends Comment> List<T> sort(List<T> comments) {
-    Collections.sort(comments, COMMENT_ORDER);
+    comments.sort(COMMENT_ORDER);
     return comments;
   }
 
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 16546f9..d9a4cae 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
@@ -54,6 +56,8 @@
 
 /** An authenticated user. */
 public class IdentifiedUser extends CurrentUser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Create an IdentifiedUser, ignoring any per-request state. */
   @Singleton
   public static class GenericFactory {
@@ -375,8 +379,13 @@
     if (effectiveGroups == null) {
       if (authConfig.isIdentityTrustable(state().getExternalIds())) {
         effectiveGroups = groupBackend.membershipsOf(this);
+        logger.atFinest().log(
+            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
       } else {
         effectiveGroups = registeredGroups;
+        logger.atFinest().log(
+            "%s has a non-trusted identity, falling back to %s as known groups",
+            getLoggableName(), lazy(registeredGroups::getKnownGroups));
       }
     }
     return effectiveGroups;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index ec8620d..fa6cd6c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -376,6 +376,8 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
+    logger.atFine().log("Read star labels from %s", refName);
+
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
       return StarRef.MISSING;
@@ -448,6 +450,7 @@
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
       throws IOException, OrmException, InvalidLabelsException {
+    logger.atFine().log("Update star labels in %s (labels=%s)", refName, labels);
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -485,6 +488,7 @@
       return;
     }
 
+    logger.atFine().log("Delete star labels in %s", refName);
     RefUpdate u = repo.updateRef(refName);
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(oldObjectId);
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 76bfcfd..2cde94c 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -154,12 +154,14 @@
   @Override
   public void evict(@Nullable Account.Id accountId) {
     if (accountId != null) {
+      logger.atFine().log("Evict account %d", accountId.get());
       byId.invalidate(accountId);
     }
   }
 
   @Override
   public void evictAll() {
+    logger.atFine().log("Evict all accounts");
     byId.invalidateAll();
   }
 
@@ -179,6 +181,7 @@
 
     @Override
     public Optional<AccountState> load(Account.Id who) throws Exception {
+      logger.atFine().log("Loading account %s", who);
       return accounts.get(who);
     }
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 6aeb691..3bdc71a 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -77,6 +78,7 @@
  */
 public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
   private final Account.Id accountId;
+  private final AllUsersName allUsersName;
   private final Repository repo;
   private final String ref;
 
@@ -87,8 +89,9 @@
   private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
   private List<ValidationError> validationErrors;
 
-  public AccountConfig(Account.Id accountId, Repository allUsersRepo) {
+  public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
     this.accountId = checkNotNull(accountId, "accountId");
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
     this.ref = RefNames.refsUsers(accountId);
   }
@@ -99,7 +102,7 @@
   }
 
   public AccountConfig load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -242,7 +245,7 @@
           new Preferences(
               accountId,
               readConfig(Preferences.PREFERENCES_CONFIG),
-              Preferences.readDefaultConfig(repo),
+              Preferences.readDefaultConfig(allUsersName, repo),
               this);
 
       projectWatches.parse();
@@ -253,7 +256,8 @@
       projectWatches = new ProjectWatches(accountId, new Config(), this);
 
       preferences =
-          new Preferences(accountId, new Config(), Preferences.readDefaultConfig(repo), this);
+          new Preferences(
+              accountId, new Config(), Preferences.readDefaultConfig(allUsersName, repo), this);
     }
 
     Ref externalIdsRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index e56ad72..1854dc1 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -315,4 +316,11 @@
     }
     return properties;
   }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(getAccount().getId());
+    return h.toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index b74365f..1a61c02 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -134,7 +134,9 @@
   private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     return AccountState.fromAccountConfig(
-        allUsersName, externalIds, new AccountConfig(accountId, allUsersRepository).load());
+        allUsersName,
+        externalIds,
+        new AccountConfig(accountId, allUsersName, allUsersRepository).load());
   }
 
   public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5876b07..fe944ae 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -368,7 +368,7 @@
 
   private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
       throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
     afterReadRevision.run();
     return accountConfig;
   }
diff --git a/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
index 803d491..1b15512 100644
--- a/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/java/com/google/gerrit/server/account/GroupBackends.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -25,12 +27,7 @@
 public class GroupBackends {
 
   public static final Comparator<GroupReference> GROUP_REF_NAME_COMPARATOR =
-      new Comparator<GroupReference>() {
-        @Override
-        public int compare(GroupReference a, GroupReference b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
+      comparing(GroupReference::getName);
 
   /**
    * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e7aae15..1f8cb88 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -116,6 +116,7 @@
   @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
+      logger.atFine().log("Evict group %s by ID", groupId.get());
       byId.invalidate(groupId);
     }
   }
@@ -123,6 +124,7 @@
   @Override
   public void evict(AccountGroup.NameKey groupName) {
     if (groupName != null) {
+      logger.atFine().log("Evict group '%s' by name", groupName.get());
       byName.invalidate(groupName.get());
     }
   }
@@ -130,6 +132,7 @@
   @Override
   public void evict(AccountGroup.UUID groupUuid) {
     if (groupUuid != null) {
+      logger.atFine().log("Evict group %s by UUID", groupUuid.get());
       byUUID.invalidate(groupUuid.get());
     }
   }
@@ -144,6 +147,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
+      logger.atFine().log("Loading group %s by ID", key);
       return groupQueryProvider.get().byId(key);
     }
   }
@@ -158,6 +162,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
+      logger.atFine().log("Loading group '%s' by name", name);
       return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
     }
   }
@@ -172,6 +177,7 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
+      logger.atFine().log("Loading group %s by UUID", uuid);
       return groups.getGroup(new AccountGroup.UUID(uuid));
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f262a79..5fb4ca1 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -112,6 +112,7 @@
   @Override
   public void evictGroupsWithMember(Account.Id memberId) {
     if (memberId != null) {
+      logger.atFine().log("Evict groups with member %d", memberId.get());
       groupsWithMember.invalidate(memberId);
     }
   }
@@ -119,9 +120,11 @@
   @Override
   public void evictParentGroupsOf(AccountGroup.UUID groupId) {
     if (groupId != null) {
+      logger.atFine().log("Evict parent groups of %s", groupId.get());
       parentGroups.invalidate(groupId);
 
       if (!AccountGroup.isInternalGroup(groupId)) {
+        logger.atFine().log("Evict external group %s", groupId.get());
         external.invalidate(EXTERNAL_NAME);
       }
     }
@@ -148,6 +151,7 @@
 
     @Override
     public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) throws OrmException {
+      logger.atFine().log("Loading groups with member %s", memberId);
       return groupQueryProvider
           .get()
           .byMember(memberId)
@@ -168,6 +172,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+      logger.atFine().log("Loading parent groups of %s", key);
       return groupQueryProvider
           .get()
           .bySubgroup(key)
@@ -187,6 +192,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
+      logger.atFine().log("Loading all external groups");
       return groups.getExternalGroups().collect(toImmutableList());
     }
   }
diff --git a/java/com/google/gerrit/server/account/Preferences.java b/java/com/google/gerrit/server/account/Preferences.java
index aa09675..5bf6e26 100644
--- a/java/com/google/gerrit/server/account/Preferences.java
+++ b/java/com/google/gerrit/server/account/Preferences.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -414,25 +415,28 @@
     return urlAliases;
   }
 
-  public static GeneralPreferencesInfo readDefaultGeneralPreferences(Repository allUsersRepo)
+  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseGeneralPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  public static DiffPreferencesInfo readDefaultDiffPreferences(Repository allUsersRepo)
+  public static DiffPreferencesInfo readDefaultDiffPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseDiffPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  public static EditPreferencesInfo readDefaultEditPreferences(Repository allUsersRepo)
+  public static EditPreferencesInfo readDefaultEditPreferences(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return parseEditPreferences(readDefaultConfig(allUsersRepo), null, null);
+    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
   }
 
-  static Config readDefaultConfig(Repository allUsersRepo)
+  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(allUsersRepo);
+    defaultPrefs.load(allUsersName, allUsersRepo);
     return defaultPrefs.getConfig();
   }
 
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 04a3a95..965f1ba 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -121,7 +121,7 @@
         throws IOException, ConfigInvalidException {
       try (Repository git = repoManager.openRepository(allUsersName)) {
         VersionedAuthorizedKeys authorizedKeys = authorizedKeysFactory.create(accountId);
-        authorizedKeys.load(git);
+        authorizedKeys.load(allUsersName, git);
         return authorizedKeys;
       }
     }
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index 15f7a0ad4..bb1ade7 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -14,37 +14,99 @@
 
 package com.google.gerrit.server.account.externalids;
 
+import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
+import static java.util.stream.Collectors.toList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multimaps;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.SetMultimap;
-import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import java.util.Collection;
 
-/**
- * Cache value containing all external IDs.
- *
- * <p>All returned fields are unmodifiable.
- */
+/** Cache value containing all external IDs. */
 @AutoValue
 public abstract class AllExternalIds {
-  static AllExternalIds create(Multimap<Id, ExternalId> byAccount) {
-    SetMultimap<String, ExternalId> byEmailCopy =
-        MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(1).build();
-    byAccount
-        .values()
-        .stream()
-        .filter(e -> !Strings.isNullOrEmpty(e.email()))
-        .forEach(e -> byEmailCopy.put(e.email(), e));
-
+  static AllExternalIds create(SetMultimap<Account.Id, ExternalId> byAccount) {
     return new AutoValue_AllExternalIds(
-        Multimaps.unmodifiableSetMultimap(
-            MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(5).build(byAccount)),
-        byEmailCopy);
+        ImmutableSetMultimap.copyOf(byAccount), byEmailCopy(byAccount.values()));
   }
 
-  public abstract SetMultimap<Id, ExternalId> byAccount();
+  static AllExternalIds create(Collection<ExternalId> externalIds) {
+    return new AutoValue_AllExternalIds(
+        externalIds.stream().collect(toImmutableSetMultimap(e -> e.accountId(), e -> e)),
+        byEmailCopy(externalIds));
+  }
 
-  public abstract SetMultimap<String, ExternalId> byEmail();
+  private static ImmutableSetMultimap<String, ExternalId> byEmailCopy(
+      Collection<ExternalId> externalIds) {
+    return externalIds
+        .stream()
+        .filter(e -> !Strings.isNullOrEmpty(e.email()))
+        .collect(toImmutableSetMultimap(e -> e.email(), e -> e));
+  }
+
+  public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
+
+  public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
+
+  enum Serializer implements CacheSerializer<AllExternalIds> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(AllExternalIds object) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      AllExternalIdsProto.Builder allBuilder = AllExternalIdsProto.newBuilder();
+      object
+          .byAccount()
+          .values()
+          .stream()
+          .map(extId -> toProto(idConverter, extId))
+          .forEach(allBuilder::addExternalId);
+      return ProtoCacheSerializers.toByteArray(allBuilder.build());
+    }
+
+    private static ExternalIdProto toProto(ObjectIdConverter idConverter, ExternalId externalId) {
+      ExternalIdProto.Builder b =
+          ExternalIdProto.newBuilder()
+              .setKey(externalId.key().get())
+              .setAccountId(externalId.accountId().get());
+      if (externalId.email() != null) {
+        b.setEmail(externalId.email());
+      }
+      if (externalId.password() != null) {
+        b.setPassword(externalId.password());
+      }
+      if (externalId.blobId() != null) {
+        b.setBlobId(idConverter.toByteString(externalId.blobId()));
+      }
+      return b.build();
+    }
+
+    @Override
+    public AllExternalIds deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return create(
+          ProtoCacheSerializers.parseUnchecked(AllExternalIdsProto.parser(), in)
+              .getExternalIdList()
+              .stream()
+              .map(proto -> toExternalId(idConverter, proto))
+              .collect(toList()));
+    }
+
+    private static ExternalId toExternalId(ObjectIdConverter idConverter, ExternalIdProto proto) {
+      return ExternalId.create(
+          ExternalId.Key.parse(proto.getKey()),
+          new Account.Id(proto.getAccountId()),
+          // ExternalId treats null and empty strings the same, so no need to distinguish here.
+          proto.getEmail(),
+          proto.getPassword(),
+          !proto.getBlobId().isEmpty() ? idConverter.fromByteString(proto.getBlobId()) : null);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index db8ea41..96ea0cc 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -47,14 +47,12 @@
   // corresponding regular expressions in the
   // com.google.gerrit.client.account.UsernameField class.
   private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
-  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9._@-]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
   private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
 
   /** Regular expression that a username must match. */
   private static final String USER_NAME_PATTERN_REGEX =
-      "^"
-          + //
-          "("
+      "^("
           + //
           USER_NAME_PATTERN_FIRST_REGEX
           + //
@@ -67,9 +65,7 @@
           + //
           USER_NAME_PATTERN_FIRST_REGEX
           + //
-          ")"
-          + //
-          "$";
+          ")$";
 
   private static final Pattern USER_NAME_PATTERN = Pattern.compile(USER_NAME_PATTERN_REGEX);
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index a8844cd..1ac737e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -34,8 +34,7 @@
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException;
+      Collection<ExternalId> toAdd);
 
   Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 533b1c0..5f64568 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -16,9 +16,8 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
@@ -60,8 +59,7 @@
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException {
+      Collection<ExternalId> toAdd) {
     updateCache(
         oldNotesRev,
         newNotesRev,
@@ -121,17 +119,17 @@
   private void updateCache(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
-      Consumer<Multimap<Account.Id, ExternalId>> update) {
+      Consumer<SetMultimap<Account.Id, ExternalId>> update) {
     lock.lock();
     try {
-      ListMultimap<Account.Id, ExternalId> m;
+      SetMultimap<Account.Id, ExternalId> m;
       if (!ObjectId.zeroId().equals(oldNotesRev)) {
         m =
             MultimapBuilder.hashKeys()
-                .arrayListValues()
+                .hashSetValues()
                 .build(extIdsByAccount.get(oldNotesRev).byAccount());
       } else {
-        m = MultimapBuilder.hashKeys().arrayListValues().build();
+        m = MultimapBuilder.hashKeys().hashSetValues().build();
       }
       update.accept(m);
       extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
@@ -152,13 +150,10 @@
 
     @Override
     public AllExternalIds load(ObjectId notesRev) throws Exception {
-      Multimap<Account.Id, ExternalId> extIdsByAccount =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      for (ExternalId extId : externalIdReader.all(notesRev)) {
-        extId.checkThatBlobIdIsSet();
-        extIdsByAccount.put(extId.accountId(), extId);
-      }
-      return AllExternalIds.create(extIdsByAccount);
+      logger.atFine().log("Loading external IDs (revision=%s)", notesRev);
+      ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
+      externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
+      return AllExternalIds.create(externalIds);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index 228b1e6..fc311e7 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.server.account.externalids.ExternalIdCacheImpl.Loader;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
 import org.eclipse.jgit.lib.ObjectId;
@@ -23,7 +24,7 @@
 public class ExternalIdModule extends CacheModule {
   @Override
   protected void configure() {
-    cache(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
+    persist(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
         // The cached data is potentially pretty large and we are always only interested
         // in the latest value. However, due to a race condition, it is possible for different
         // threads to observe different values of the meta ref, and hence request different keys
@@ -32,7 +33,11 @@
         // memory.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(Loader.class);
+        .loader(Loader.class)
+        .diskLimit(-1)
+        .version(1)
+        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
+        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
 
     bind(ExternalIdCacheImpl.class);
     bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 7cd1db0..b117888 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -117,24 +118,32 @@
     private final AccountCache accountCache;
     private final Provider<AccountIndexer> accountIndexer;
     private final MetricMaker metricMaker;
+    private final AllUsersName allUsersName;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
         AccountCache accountCache,
         Provider<AccountIndexer> accountIndexer,
-        MetricMaker metricMaker) {
+        MetricMaker metricMaker,
+        AllUsersName allUsersName) {
       this.externalIdCache = externalIdCache;
       this.accountCache = accountCache;
       this.accountIndexer = accountIndexer;
       this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
+              externalIdCache,
+              accountCache,
+              accountIndexer,
+              metricMaker,
+              allUsersName,
+              allUsersRepo)
           .load();
     }
 
@@ -142,7 +151,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
+              externalIdCache,
+              accountCache,
+              accountIndexer,
+              metricMaker,
+              allUsersName,
+              allUsersRepo)
           .load(rev);
     }
   }
@@ -151,23 +165,30 @@
   public static class FactoryNoReindex implements ExternalIdNotesLoader {
     private final ExternalIdCache externalIdCache;
     private final MetricMaker metricMaker;
+    private final AllUsersName allUsersName;
 
     @Inject
-    FactoryNoReindex(ExternalIdCache externalIdCache, MetricMaker metricMaker) {
+    FactoryNoReindex(
+        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load();
+      return new ExternalIdNotes(
+              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load(rev);
+      return new ExternalIdNotes(
+              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+          .load(rev);
     }
   }
 
@@ -177,10 +198,15 @@
    *
    * @return read-only {@link ExternalIdNotes} instance
    */
-  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo)
+  public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .setReadOnly()
         .load();
   }
@@ -195,10 +221,16 @@
    *     external IDs will be empty
    * @return read-only {@link ExternalIdNotes} instance
    */
-  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo, @Nullable ObjectId rev)
+  public static ExternalIdNotes loadReadOnly(
+      AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .setReadOnly()
         .load(rev);
   }
@@ -213,16 +245,23 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(Repository allUsersRepo)
+  public static ExternalIdNotes loadNoCacheUpdate(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+            new DisabledExternalIdCache(),
+            null,
+            null,
+            new DisabledMetricMaker(),
+            allUsersName,
+            allUsersRepo)
         .load();
   }
 
   private final ExternalIdCache externalIdCache;
   @Nullable private final AccountCache accountCache;
   @Nullable private final Provider<AccountIndexer> accountIndexer;
+  private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
 
@@ -243,6 +282,7 @@
       @Nullable AccountCache accountCache,
       @Nullable Provider<AccountIndexer> accountIndexer,
       MetricMaker metricMaker,
+      AllUsersName allUsersName,
       Repository allUsersRepo) {
     this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.accountCache = accountCache;
@@ -251,6 +291,7 @@
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
+    this.allUsersName = checkNotNull(allUsersName, "allUsersRepo");
     this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
   }
 
@@ -279,7 +320,7 @@
    * @return {@link ExternalIdNotes} instance for chaining
    */
   private ExternalIdNotes load() throws IOException, ConfigInvalidException {
-    load(repo);
+    load(allUsersName, repo);
     return this;
   }
 
@@ -298,10 +339,10 @@
       return load();
     }
     if (ObjectId.zeroId().equals(rev)) {
-      load(repo, null);
+      load(allUsersName, repo, null);
       return this;
     }
-    load(repo, rev);
+    load(allUsersName, repo, rev);
     return this;
   }
 
@@ -615,6 +656,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    logger.atFine().log("Reading external IDs");
+
     noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
 
     if (afterReadRevision != null) {
@@ -636,7 +679,7 @@
    *
    * <p>Must only be called after committing changes.
    *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
    *
    * <p>No eviction from account cache and no reindex if this instance was created by {@link
    * FactoryNoReindex}.
@@ -651,7 +694,7 @@
    *
    * <p>Must only be called after committing changes.
    *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
    *
    * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
    *
@@ -701,6 +744,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating external IDs");
+
     if (Strings.isNullOrEmpty(commit.getMessage())) {
       commit.setMessage("Update external IDs\n");
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index ee6d5cd..cf5500e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -99,7 +99,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo).all();
     }
   }
 
@@ -118,7 +118,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo, rev).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).all();
     }
   }
 
@@ -127,7 +127,7 @@
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
     }
   }
 
@@ -137,7 +137,7 @@
     checkReadEnabled();
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(repo, rev).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 0756a72..14ead2f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -59,14 +59,14 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(repo));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo));
     }
   }
 
   public List<ConsistencyProblemInfo> check(ObjectId rev)
       throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(repo, rev));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev));
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index a49f8c4..463c23e 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -53,6 +55,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.Check;
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -115,6 +118,7 @@
   private final CommitApiImpl.Factory commitApi;
   private final DashboardApiImpl.Factory dashboardApi;
   private final CheckAccess checkAccess;
+  private final Check check;
   private final Provider<ListDashboards> listDashboards;
   private final GetHead getHead;
   private final SetHead setHead;
@@ -148,6 +152,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -181,6 +186,7 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
@@ -216,6 +222,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -249,6 +256,7 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
@@ -284,6 +292,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -316,6 +325,7 @@
     this.createAccessChange = createAccessChange;
     this.dashboardApi = dashboardApi;
     this.checkAccess = checkAccess;
+    this.check = check;
     this.listDashboards = listDashboards;
     this.getHead = getHead;
     this.setHead = setHead;
@@ -372,15 +382,6 @@
   }
 
   @Override
-  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
-    try {
-      return checkAccess.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check access rights", e);
-    }
-  }
-
-  @Override
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
@@ -399,6 +400,24 @@
   }
 
   @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
+    }
+  }
+
+  @Override
+  public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+    try {
+      return check.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check project", e);
+    }
+  }
+
+  @Override
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 5efdc5a..d85668e 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -24,9 +24,34 @@
         "//java/org/apache/commons/net",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
+        "//lib:autolink",
         "//lib:automaton",
         "//lib:blame-cache",
-        "//lib:grappa",
+        "//lib:flexmark",
+        "//lib:flexmark-ext-abbreviation",
+        "//lib:flexmark-ext-anchorlink",
+        "//lib:flexmark-ext-autolink",
+        "//lib:flexmark-ext-definition",
+        "//lib:flexmark-ext-emoji",
+        "//lib:flexmark-ext-escaped-character",
+        "//lib:flexmark-ext-footnotes",
+        "//lib:flexmark-ext-gfm-issues",
+        "//lib:flexmark-ext-gfm-strikethrough",
+        "//lib:flexmark-ext-gfm-tables",
+        "//lib:flexmark-ext-gfm-tasklist",
+        "//lib:flexmark-ext-gfm-users",
+        "//lib:flexmark-ext-ins",
+        "//lib:flexmark-ext-jekyll-front-matter",
+        "//lib:flexmark-ext-superscript",
+        "//lib:flexmark-ext-tables",
+        "//lib:flexmark-ext-toc",
+        "//lib:flexmark-ext-typographic",
+        "//lib:flexmark-ext-wikilink",
+        "//lib:flexmark-ext-yaml-front-matter",
+        "//lib:flexmark-formatter",
+        "//lib:flexmark-html-parser",
+        "//lib:flexmark-profile-pegdown",
+        "//lib:flexmark-util",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -35,7 +60,6 @@
         "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
-        "//lib:pegdown",
         "//lib:protobuf",
         "//lib:servlet-api-3_1",
         "//lib:soy",
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 8d12d32..ede8050 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -351,6 +351,7 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
+      logger.atFine().log("Loading account for username %s", username);
       return externalIds
           .get(ExternalId.Key.create(SCHEME_GERRIT, username))
           .map(ExternalId::accountId);
@@ -367,6 +368,7 @@
 
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
+      logger.atFine().log("Loading group for member with username %s", username);
       final DirContext ctx = helper.open();
       try {
         return helper.queryForGroups(ctx, username, null);
@@ -386,6 +388,7 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
+      logger.atFine().log("Loading groupDn %s", groupDn);
       final DirContext ctx = helper.open();
       try {
         Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 13a09a1..4c364c5 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.IntKeyCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 11f2034..3435652 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheStats;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.metrics.CallbackMetric;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
@@ -95,7 +96,7 @@
   }
 
   private static String metricNameOf(DynamicMap.Entry<Cache<?, ?>> e) {
-    if ("gerrit".equals(e.getPluginName())) {
+    if (PluginName.GERRIT.equals(e.getPluginName())) {
       return e.getExportName();
     }
     return String.format("plugin/%s/%s", e.getPluginName(), e.getExportName());
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index ca399e7..2878624 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.Weigher;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
diff --git a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index be06601..a7fdbbd 100644
--- a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.RemovalListener;
 import com.google.common.cache.RemovalNotification;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -36,7 +37,7 @@
 
   private final DynamicSet<CacheRemovalListener> listeners;
   private final String cacheName;
-  private String pluginName = "gerrit";
+  private String pluginName = PluginName.GERRIT;
 
   @Inject
   ForwardingRemovalListener(
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
index 0239ea2..5635f44 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
@@ -16,6 +16,7 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.time.Duration;
 
 /** Configure a persistent cache declared within a {@link CacheModule} instance. */
@@ -30,6 +31,9 @@
   PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
   @Override
+  PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
+
+  @Override
   PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
   PersistentCacheBinding<K, V> version(int version);
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheDef.java b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
index 9bd120f..8de685c 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheDef.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache;
 
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
 public interface PersistentCacheDef<K, V> extends CacheDef<K, V> {
   long diskLimit();
 
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 2db9e56..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -20,6 +20,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
@@ -63,6 +65,11 @@
   }
 
   @Override
+  public PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration) {
+    return (PersistentCacheBinding<K, V>) super.expireFromMemoryAfterAccess(duration);
+  }
+
+  @Override
   public PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
     return (PersistentCacheBinding<K, V>) super.weigher(clazz);
   }
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index fc57a11..f6418e3 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -8,6 +8,8 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:h2",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 78de67dd..48c0a5b 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -17,9 +17,9 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
 
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 9abccbc..af1228d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -28,6 +28,8 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -74,15 +76,17 @@
 
     if (cacheDir != null) {
       executor =
-          Executors.newFixedThreadPool(
-              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build());
+          new LoggingContextAwareExecutorService(
+              Executors.newFixedThreadPool(
+                  1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build()));
       cleanup =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("DiskCache-Prune-%d")
-                  .setDaemon(true)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("DiskCache-Prune-%d")
+                      .setDaemon(true)
+                      .build()));
     } else {
       executor = null;
       cleanup = null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 6878e46..606fdf0 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -25,8 +25,8 @@
 import com.google.common.hash.BloomFilter;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.io.InvalidClassException;
@@ -235,6 +235,8 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
+      logger.atFine().log("Loading value for %s from cache", key);
+
       if (store.mightContain(key)) {
         ValueHolder<V> h = store.getIfPresent(key);
         if (h != null) {
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 44e2bb2..591883e 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -17,7 +17,7 @@
 import com.google.common.hash.Funnel;
 import com.google.common.hash.Funnels;
 import com.google.common.hash.PrimitiveSink;
-import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.io.IOException;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..957a153
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "serialize",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
index 59fc946..28cd6eb 100644
--- a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/CacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 08deecd..2d41f2c 100644
--- a/java/com/google/gerrit/server/cache/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 /**
  * Interface for serializing/deserializing a type to/from a persistent cache.
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/EnumCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
index c5be783..7856e55 100644
--- a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
similarity index 95%
rename from java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
index a07c004..cff8682 100644
--- a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
index 5eddb71..3195941 100644
--- a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/JavaCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
index 55358bc..ee71846 100644
--- a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
new file mode 100644
index 0000000..500875d
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+public enum ObjectIdCacheSerializer implements CacheSerializer<ObjectId> {
+  INSTANCE;
+
+  @Override
+  public byte[] serialize(ObjectId object) {
+    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    object.copyRawTo(buf, 0);
+    return buf;
+  }
+
+  @Override
+  public ObjectId deserialize(byte[] in) {
+    if (in == null || in.length != Constants.OBJECT_ID_LENGTH) {
+      throw new IllegalArgumentException("Failed to deserialize ObjectId");
+    }
+    return ObjectId.fromRaw(in);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
similarity index 98%
rename from java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
rename to java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
index c6fc0b9..4e0b106 100644
--- a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
+++ b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
diff --git a/java/com/google/gerrit/server/cache/StringSerializer.java b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
similarity index 94%
rename from java/com/google/gerrit/server/cache/StringSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
index 1e456c7..525b75b 100644
--- a/java/com/google/gerrit/server/cache/StringSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -23,7 +23,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.CodingErrorAction;
 
-public enum StringSerializer implements CacheSerializer<String> {
+public enum StringCacheSerializer implements CacheSerializer<String> {
   INSTANCE;
 
   @Override
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index ed412af..9a9f1ef 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:protobuf",
         "//lib/commons:lang3",
diff --git a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
index 5d41490..b339e24 100644
--- a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
+++ b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
@@ -18,12 +18,16 @@
 
 /** Static utilities for testing cache serializers. */
 public class CacheSerializerTestUtil {
-  public static ByteString bytes(int... ints) {
+  public static ByteString byteString(int... ints) {
+    return ByteString.copyFrom(byteArray(ints));
+  }
+
+  public static byte[] byteArray(int... ints) {
     byte[] bytes = new byte[ints.length];
     for (int i = 0; i < ints.length; i++) {
       bytes[i] = (byte) ints[i];
     }
-    return ByteString.copyFrom(bytes);
+    return bytes;
   }
 
   private CacheSerializerTestUtil() {}
diff --git a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
index 19c5b67..b902c1c 100644
--- a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
@@ -32,7 +32,7 @@
 /**
  * Subject about classes that are serialized into persistent caches.
  *
- * <p>Hand-written {@link com.google.gerrit.server.cache.CacheSerializer CacheSerializer}
+ * <p>Hand-written {@link com.google.gerrit.server.cache.serialize.CacheSerializer CacheSerializer}
  * implementations depend on the exact representation of the data stored in a class, so it is
  * important to verify any assumptions about the structure of the serialized classes. This class
  * contains assertions about serialized classes, and should be used for every class that has a
@@ -100,4 +100,11 @@
         .named("no-argument abstract methods on %s", actual().getName())
         .isEqualTo(expectedMethods);
   }
+
+  public void extendsClass(Type superclassType) {
+    isNotNull();
+    assertThat(actual().getGenericSuperclass())
+        .named("superclass of %s", actual().getName())
+        .isEqualTo(superclassType);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index e8c55e8..38c97f7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -552,8 +552,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).project(ctx.getProject()).ref(refName);
     try {
       try (CommitReceivedEvent event =
           new CommitReceivedEvent(
@@ -565,7 +563,7 @@
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
-                perm,
+                permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
                 new Branch.NameKey(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 04b649b..173d1da 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -1207,6 +1207,12 @@
     Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
     Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
+
+    // Check if the user has the permission to remove a reviewer. This means we can bypass the
+    // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
+    // permission checks.
+    boolean canRemoveAnyReviewer =
+        permissionBackendForChange(userProvider.get(), cd).test(ChangePermission.REMOVE_REVIEWER);
     for (LabelInfo label : labels) {
       if (label.all == null) {
         continue;
@@ -1214,8 +1220,9 @@
       for (ApprovalInfo ai : label.all) {
         Account.Id id = new Account.Id(ai._accountId);
 
-        if (removeReviewerControl.testRemoveReviewer(
-            cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+        if (canRemoveAnyReviewer
+            || removeReviewerControl.testRemoveReviewer(
+                cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
           removable.add(id);
         } else {
           fixed.add(id);
@@ -1232,7 +1239,8 @@
       for (AccountInfo ai : ccs) {
         if (ai._accountId != null) {
           Account.Id id = new Account.Id(ai._accountId);
-          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+          if (canRemoveAnyReviewer
+              || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
             removable.add(id);
           }
         }
@@ -1397,8 +1405,7 @@
         out.commitWithFooters =
             mergeUtilFactory
                 .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(
-                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 24685af..a6786d8 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.EnumCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index 8f8925a..d5d54ec 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -63,13 +63,13 @@
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       for (ExternalIncludedIn ext : externalIncludedIn) {
         ListMultimap<String, String> extIncludedIns =
-            ext.getIncludedIn(project.get(), rev.name(), d.getTags(), d.getBranches());
+            ext.getIncludedIn(project.get(), rev.name(), d.tags(), d.branches());
         if (extIncludedIns != null) {
           external.putAll(extIncludedIns);
         }
       }
       return new IncludedInInfo(
-          d.getBranches(), d.getTags(), (!external.isEmpty() ? external.asMap() : null));
+          d.branches(), d.tags(), (!external.isEmpty() ? external.asMap() : null));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index d1bc0a2..62e9454 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -14,6 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -22,7 +29,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -93,11 +99,9 @@
     parseCommits(allTagsAndBranches);
     Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
 
-    Result detail = new Result();
-    detail.setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
-    detail.setTags(getMatchingRefNames(allMatchingTagsAndBranches, tags));
-
-    return detail;
+    return new AutoValue_IncludedInResolver_Result(
+        getMatchingRefNames(allMatchingTagsAndBranches, branches),
+        getMatchingRefNames(allMatchingTagsAndBranches, tags));
   }
 
   private boolean includedInOne(Collection<Ref> refs) throws IOException {
@@ -151,15 +155,7 @@
    */
   private void partition(List<RevCommit> before, List<RevCommit> after) {
     int insertionPoint =
-        Collections.binarySearch(
-            tipsByCommitTime,
-            target,
-            new Comparator<RevCommit>() {
-              @Override
-              public int compare(RevCommit c1, RevCommit c2) {
-                return c1.getCommitTime() - c2.getCommitTime();
-              }
-            });
+        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
     if (insertionPoint < 0) {
       insertionPoint = -(insertionPoint + 1);
     }
@@ -175,15 +171,14 @@
    * Returns the short names of refs which are as well in the matchingRefs list as well as in the
    * allRef list.
    */
-  private static List<String> getMatchingRefNames(
+  private static ImmutableSortedSet<String> getMatchingRefNames(
       Set<String> matchingRefs, Collection<Ref> allRefs) {
-    List<String> refNames = Lists.newArrayListWithCapacity(matchingRefs.size());
-    for (Ref r : allRefs) {
-      if (matchingRefs.contains(r.getName())) {
-        refNames.add(Repository.shortenRefName(r.getName()));
-      }
-    }
-    return refNames;
+    return allRefs
+        .stream()
+        .map(Ref::getName)
+        .filter(matchingRefs::contains)
+        .map(Repository::shortenRefName)
+        .collect(toImmutableSortedSet(naturalOrder()));
   }
 
   /** Parse commit of ref and store the relation between ref and commit. */
@@ -211,43 +206,14 @@
       }
       commitToRef.put(commit, ref.getName());
     }
-    tipsByCommitTime = Lists.newArrayList(commitToRef.keySet());
-    sortOlderFirst(tipsByCommitTime);
+    tipsByCommitTime =
+        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
   }
 
-  private void sortOlderFirst(List<RevCommit> tips) {
-    Collections.sort(
-        tips,
-        new Comparator<RevCommit>() {
-          @Override
-          public int compare(RevCommit c1, RevCommit c2) {
-            return c1.getCommitTime() - c2.getCommitTime();
-          }
-        });
-  }
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableSortedSet<String> branches();
 
-  public static class Result {
-    private List<String> branches;
-    private List<String> tags;
-
-    public Result() {}
-
-    public void setBranches(List<String> b) {
-      Collections.sort(b);
-      branches = b;
-    }
-
-    public List<String> getBranches() {
-      return branches;
-    }
-
-    public void setTags(List<String> t) {
-      Collections.sort(t);
-      tags = t;
-    }
-
-    public List<String> getTags() {
-      return tags;
-    }
+    public abstract ImmutableSortedSet<String> tags();
   }
 }
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index ba54361..2d00886 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -26,12 +26,12 @@
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d71a93d..8bd6c17 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -323,9 +323,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
-
     String refName = getPatchSetId().toRefName();
     try (CommitReceivedEvent event =
         new CommitReceivedEvent(
@@ -340,7 +337,7 @@
             ctx.getIdentifiedUser())) {
       commitValidatorsFactory
           .forGerritCommits(
-              perm,
+              permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
               origNotes.getChange().getDest(),
               ctx.getIdentifiedUser(),
               new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 909ea3a..1f216f0 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -165,8 +165,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(
-                  original, baseCommit, notes, changeOwner, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.getId());
     } else {
       newCommitMessage = original.getFullMessage();
     }
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index cff1ac7..916a62b 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -34,7 +34,6 @@
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Deque;
 import java.util.HashSet;
 import java.util.List;
@@ -110,7 +109,7 @@
     for (Map.Entry<Project.NameKey, Collection<ChangeData>> e : byProject.asMap().entrySet()) {
       sortedByProject.add(sortProject(e.getKey(), e.getValue()));
     }
-    Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
+    sortedByProject.sort(PROJECT_LIST_SORTER);
     return Iterables.concat(sortedByProject);
   }
 
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 75a9323..49def5f 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -125,7 +125,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
     if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 16c7508..ffa7b5a 100644
--- a/java/com/google/gerrit/server/config/CacheResource.java
+++ b/java/com/google/gerrit/server/config/CacheResource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
@@ -52,7 +53,7 @@
   }
 
   public static String cacheNameOf(String plugin, String name) {
-    if ("gerrit".equals(plugin)) {
+    if (PluginName.GERRIT.equals(plugin)) {
       return name;
     }
     return plugin + "-" + name;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b6a257b..0761d2e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -170,6 +170,7 @@
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -244,6 +245,7 @@
     install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 2e97c06..f552434 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -19,6 +19,7 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -82,14 +83,18 @@
       return MoreExecutors.newDirectExecutorService();
     }
     return MoreExecutors.listeningDecorator(
-        MoreExecutors.getExitingExecutorService(
-            new ThreadPoolExecutor(
-                1,
-                poolSize,
-                10,
-                TimeUnit.MINUTES,
-                new ArrayBlockingQueue<Runnable>(poolSize),
-                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
-                new ThreadPoolExecutor.CallerRunsPolicy())));
+        new LoggingContextAwareExecutorService(
+            MoreExecutors.getExitingExecutorService(
+                new ThreadPoolExecutor(
+                    1,
+                    poolSize,
+                    10,
+                    TimeUnit.MINUTES,
+                    new ArrayBlockingQueue<Runnable>(poolSize),
+                    new ThreadFactoryBuilder()
+                        .setNameFormat("ChangeUpdate-%d")
+                        .setDaemon(true)
+                        .build(),
+                    new ThreadPoolExecutor.CallerRunsPolicy()))));
   }
 }
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index a7f9a05..2eb46f1 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -14,30 +14,33 @@
 
 package com.google.gerrit.server.documentation;
 
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.ALL;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.HARDWRAPS;
+import static com.vladsch.flexmark.profiles.pegdown.Extensions.SUPPRESS_ALL_HTML;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.pegdown.Extensions.ALL;
-import static org.pegdown.Extensions.HARDWRAPS;
-import static org.pegdown.Extensions.SUPPRESS_ALL_HTML;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.vladsch.flexmark.Extension;
+import com.vladsch.flexmark.ast.Block;
+import com.vladsch.flexmark.ast.Heading;
+import com.vladsch.flexmark.ast.Node;
+import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.util.options.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
-import org.pegdown.LinkRenderer;
-import org.pegdown.PegDownProcessor;
-import org.pegdown.ToHtmlSerializer;
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.TextNode;
 
 public class MarkdownFormatter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -48,9 +51,9 @@
     AtomicBoolean file = new AtomicBoolean();
     String src;
     try {
-      src = readPegdownCss(file);
+      src = readFlexMarkJavaCss(file);
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       src = "";
     }
     defaultCss = file.get() ? null : src;
@@ -61,9 +64,9 @@
       return defaultCss;
     }
     try {
-      return readPegdownCss(new AtomicBoolean());
+      return readFlexMarkJavaCss(new AtomicBoolean());
     } catch (IOException err) {
-      logger.atWarning().withCause(err).log("Cannot load pegdown.css");
+      logger.atWarning().withCause(err).log("Cannot load flexmark-java.css");
       return "";
     }
   }
@@ -81,8 +84,28 @@
     return this;
   }
 
+  private MutableDataHolder markDownOptions() {
+    int options = ALL & ~(HARDWRAPS);
+    if (suppressHtml) {
+      options |= SUPPRESS_ALL_HTML;
+    }
+
+    MutableDataHolder optionsExt =
+        PegdownOptionsAdapter.flexmarkOptions(
+                options, MarkdownFormatterHeader.HeadingExtension.create())
+            .toMutable();
+
+    ArrayList<Extension> extensions = new ArrayList<>();
+    for (Extension extension : optionsExt.get(com.vladsch.flexmark.parser.Parser.EXTENSIONS)) {
+      extensions.add(extension);
+    }
+
+    return optionsExt;
+  }
+
   public byte[] markdownToDocHtml(String md, String charEnc) throws UnsupportedEncodingException {
-    RootNode root = parseMarkdown(md);
+    Node root = parseMarkdown(md);
+    HtmlRenderer renderer = HtmlRenderer.builder(markDownOptions()).build();
     String title = findTitle(root);
 
     StringBuilder html = new StringBuilder();
@@ -100,7 +123,7 @@
     html.append("\n</style>");
     html.append("</head>");
     html.append("<body>\n");
-    html.append(new ToHtmlSerializer(new LinkRenderer()).toHtml(root));
+    html.append(renderer.render(root));
     html.append("\n</body></html>");
     return html.toString().getBytes(charEnc);
   }
@@ -111,38 +134,36 @@
   }
 
   private String findTitle(Node root) {
-    if (root instanceof HeaderNode) {
-      HeaderNode h = (HeaderNode) root;
-      if (h.getLevel() == 1 && h.getChildren() != null && !h.getChildren().isEmpty()) {
-        StringBuilder b = new StringBuilder();
-        for (Node n : root.getChildren()) {
-          if (n instanceof TextNode) {
-            b.append(((TextNode) n).getText());
-          }
-        }
-        return b.toString();
+    if (root instanceof Heading) {
+      Heading h = (Heading) root;
+      if (h.getLevel() == 1 && h.hasChildren()) {
+        TextCollectingVisitor collectingVisitor = new TextCollectingVisitor();
+        return collectingVisitor.collectAndGetText(h);
       }
     }
 
-    for (Node n : root.getChildren()) {
-      String title = findTitle(n);
-      if (title != null) {
-        return title;
+    if (root instanceof Block && root.hasChildren()) {
+      Node child = root.getFirstChild();
+      while (child != null) {
+        String title = findTitle(child);
+        if (title != null) {
+          return title;
+        }
+        child = child.getNext();
       }
     }
+
     return null;
   }
 
-  private RootNode parseMarkdown(String md) {
-    int options = ALL & ~(HARDWRAPS);
-    if (suppressHtml) {
-      options |= SUPPRESS_ALL_HTML;
-    }
-    return new PegDownProcessor(options).parseMarkdown(md.toCharArray());
+  private Node parseMarkdown(String md) {
+    Parser parser = Parser.builder(markDownOptions()).build();
+    Node document = parser.parse(md);
+    return document;
   }
 
-  private static String readPegdownCss(AtomicBoolean file) throws IOException {
-    String name = "pegdown.css";
+  private static String readFlexMarkJavaCss(AtomicBoolean file) throws IOException {
+    String name = "flexmark-java.css";
     URL url = MarkdownFormatter.class.getResource(name);
     if (url == null) {
       throw new FileNotFoundException("Resource " + name);
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
new file mode 100644
index 0000000..00471fd
--- /dev/null
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.documentation;
+
+import com.vladsch.flexmark.ast.Heading;
+import com.vladsch.flexmark.ast.Node;
+import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
+import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
+import com.vladsch.flexmark.html.CustomNodeRenderer;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
+import com.vladsch.flexmark.html.HtmlWriter;
+import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
+import com.vladsch.flexmark.html.renderer.NodeRenderer;
+import com.vladsch.flexmark.html.renderer.NodeRendererContext;
+import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
+import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
+import com.vladsch.flexmark.profiles.pegdown.Extensions;
+import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.util.options.DataHolder;
+import com.vladsch.flexmark.util.options.MutableDataHolder;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class MarkdownFormatterHeader {
+  static class HeadingExtension implements HtmlRendererExtension {
+    @Override
+    public void rendererOptions(final MutableDataHolder options) {
+      // add any configuration settings to options you want to apply to everything, here
+    }
+
+    @Override
+    public void extend(final HtmlRenderer.Builder rendererBuilder, final String rendererType) {
+      rendererBuilder.nodeRendererFactory(new HeadingNodeRenderer.Factory());
+    }
+
+    static HeadingExtension create() {
+      return new HeadingExtension();
+    }
+  }
+
+  static class HeadingNodeRenderer implements NodeRenderer {
+    public HeadingNodeRenderer() {}
+
+    @Override
+    public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
+      return new HashSet<NodeRenderingHandler<? extends Node>>(
+          Arrays.asList(
+              new NodeRenderingHandler<>(
+                  AnchorLink.class,
+                  new CustomNodeRenderer<AnchorLink>() {
+                    @Override
+                    public void render(
+                        AnchorLink node, NodeRendererContext context, HtmlWriter html) {
+                      HeadingNodeRenderer.this.render(node, context);
+                    }
+                  }),
+              new NodeRenderingHandler<>(
+                  Heading.class,
+                  new CustomNodeRenderer<Heading>() {
+                    @Override
+                    public void render(Heading node, NodeRendererContext context, HtmlWriter html) {
+                      HeadingNodeRenderer.this.render(node, context, html);
+                    }
+                  })));
+    }
+
+    void render(final AnchorLink node, final NodeRendererContext context) {
+      Node parent = node.getParent();
+
+      if (parent instanceof Heading && ((Heading) parent).getLevel() == 1) {
+        // render without anchor link
+        context.renderChildren(node);
+      } else {
+        context.delegateRender();
+      }
+    }
+
+    static boolean haveExtension(int extensions, int flags) {
+      return (extensions & flags) != 0;
+    }
+
+    static boolean haveAllExtensions(int extensions, int flags) {
+      return (extensions & flags) == flags;
+    }
+
+    void render(final Heading node, final NodeRendererContext context, final HtmlWriter html) {
+      if (node.getLevel() == 1) {
+        // render without anchor link
+        final int extensions = context.getOptions().get(PegdownOptionsAdapter.PEGDOWN_EXTENSIONS);
+        if (context.getHtmlOptions().renderHeaderId
+            || haveExtension(extensions, Extensions.ANCHORLINKS)
+            || haveAllExtensions(
+                extensions, Extensions.EXTANCHORLINKS | Extensions.EXTANCHORLINKS_WRAP)) {
+          String id = context.getNodeId(node);
+          if (id != null) {
+            html.attr("id", id);
+          }
+        }
+
+        if (context.getHtmlOptions().sourcePositionParagraphLines) {
+          html.srcPos(node.getChars())
+              .withAttr()
+              .tagLine(
+                  "h" + node.getLevel(),
+                  new Runnable() {
+                    @Override
+                    public void run() {
+                      html.srcPos(node.getText()).withAttr().tag("span");
+                      context.renderChildren(node);
+                      html.tag("/span");
+                    }
+                  });
+        } else {
+          html.srcPos(node.getText())
+              .withAttr()
+              .tagLine(
+                  "h" + node.getLevel(),
+                  new Runnable() {
+                    @Override
+                    public void run() {
+                      context.renderChildren(node);
+                    }
+                  });
+        }
+      } else {
+        context.delegateRender();
+      }
+    }
+
+    public static class Factory implements DelegatingNodeRendererFactory {
+      @Override
+      public NodeRenderer create(final DataHolder options) {
+        return new HeadingNodeRenderer();
+      }
+
+      @Override
+      public Set<Class<? extends NodeRendererFactory>> getDelegates() {
+        Set<Class<? extends NodeRendererFactory>> delegates = new HashSet<>();
+        delegates.add(AnchorLinkNodeRenderer.Factory.class);
+        return delegates;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 2fbc1c7..fbce4b2 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -73,7 +73,6 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -329,10 +328,9 @@
       }
     }
     // Sort by original parent order.
-    Collections.sort(
-        ca.dependsOn,
+    ca.dependsOn.sort(
         comparing(
-            (DependencyAttribute d) -> {
+            d -> {
               for (int i = 0; i < parentNames.size(); i++) {
                 if (parentNames.get(i).equals(d.revision)) {
                   return i;
diff --git a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
index af42b08..d03eda4 100644
--- a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class PrivateStateChangedEvent extends ChangeEvent {
+public class PrivateStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "private-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 41cc701..367a38b 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -472,10 +472,12 @@
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
@@ -488,10 +490,12 @@
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
index ad32672..5e52c7b 100644
--- a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class WorkInProgressStateChangedEvent extends ChangeEvent {
+public class WorkInProgressStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "wip-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 74fba9a..5116708 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -121,8 +121,11 @@
 
   public void logEventListenerError(Object event, Object listener, Exception error) {
     logger.atWarning().log(
-        "Error in event listener %s for event %s: %s",
-        listener.getClass().getName(), event.getClass().getName(), error.getMessage());
+        "Error in event listener %s for event %s: %s - %s",
+        listener.getClass().getName(),
+        event.getClass().getName(),
+        error.getClass().getName(),
+        error.getMessage());
     logger.atFine().withCause(error).log(
         "Cause of error in event listener %s:", listener.getClass().getName());
   }
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index acd275d..2df56aa 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -40,12 +46,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (PrivateStateChangedListener l : listeners) {
         try {
           l.onPrivateStateChanged(event);
@@ -53,16 +64,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 3f9f35b..1c22561 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -41,12 +47,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (WorkInProgressStateChangedListener l : listeners) {
         try {
           l.onWorkInProgressStateChanged(event);
@@ -54,16 +65,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index f8cb4ce..af28bed3 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -169,7 +170,7 @@
 
     PrivateInternals_UiActionDescription.setMethod(dsc, e.getExportName().substring(0, d));
     PrivateInternals_UiActionDescription.setId(
-        dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+        dsc, PluginName.GERRIT.equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
     return dsc;
   }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index 1c87a63..513d909 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -14,12 +14,16 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 
 /** Print a change description for use in git command-line progress. */
 public class DefaultChangeReportFormatter implements ChangeReportFormatter {
+  private static final int SUBJECT_MAX_LENGTH = 80;
+  private static final String SUBJECT_CROP_APPENDIX = "...";
+  private static final int SUBJECT_CROP_RANGE = 10;
+
   private final String canonicalWebUrl;
 
   @Inject
@@ -37,19 +41,37 @@
     return formatChangeUrl(canonicalWebUrl, input);
   }
 
-  @Override
-  public String changeClosed(ChangeReportFormatter.Input input) {
-    return String.format(
-        "change %s closed", ChangeUtil.formatChangeUrl(canonicalWebUrl, input.change()));
+  public static String formatChangeUrl(String canonicalWebUrl, Change change) {
+    return canonicalWebUrl + "c/" + change.getProject().get() + "/+/" + change.getChangeId();
   }
 
-  private String formatChangeUrl(String url, Input input) {
+  @Override
+  public String changeClosed(ChangeReportFormatter.Input input) {
+    return String.format("change %s closed", formatChangeUrl(canonicalWebUrl, input.change()));
+  }
+
+  protected String cropSubject(String subject) {
+    if (subject.length() > SUBJECT_MAX_LENGTH) {
+      int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
+      for (int cropPosition = maxLength;
+          cropPosition > maxLength - SUBJECT_CROP_RANGE;
+          cropPosition--) {
+        if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
+          return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
+        }
+      }
+      return subject.substring(0, maxLength) + SUBJECT_CROP_APPENDIX;
+    }
+    return subject;
+  }
+
+  protected String formatChangeUrl(String url, Input input) {
     StringBuilder m =
         new StringBuilder()
             .append("  ")
-            .append(ChangeUtil.formatChangeUrl(url, input.change()))
+            .append(formatChangeUrl(url, input.change()))
             .append(" ")
-            .append(ChangeUtil.cropSubject(input.subject()));
+            .append(cropSubject(input.subject()));
     if (input.isEdit()) {
       m.append(" [EDIT]");
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 0231378..c035269 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -220,7 +219,7 @@
     } catch (IOException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
-    Collections.sort(result, CodeReviewCommit.ORDER);
+    result.sort(CodeReviewCommit.ORDER);
     return result;
   }
 
@@ -315,12 +314,10 @@
    *
    * @param n
    * @param notes
-   * @param user
    * @param psId
    * @return new message
    */
-  private String createDetailedCommitMessage(
-      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+  private String createDetailedCommitMessage(RevCommit n, ChangeNotes notes, PatchSet.Id psId) {
     Change c = notes.getChange();
     final List<FooterLine> footers = n.getFooterLines();
     final StringBuilder msgbuf = new StringBuilder();
@@ -424,12 +421,7 @@
   }
 
   public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
-    return createCommitMessageOnSubmit(
-        n,
-        mergeTip,
-        n.notes(),
-        identifiedUserFactory.create(n.notes().getChange().getOwner()),
-        n.getPatchsetId());
+    return createCommitMessageOnSubmit(n, mergeTip, n.notes(), n.getPatchsetId());
   }
 
   /**
@@ -442,14 +434,13 @@
    * @param n
    * @param mergeTip
    * @param notes
-   * @param user
    * @param id
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
+      RevCommit n, RevCommit mergeTip, ChangeNotes notes, Id id) {
     return commitMessageGenerator.generate(
-        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 1b83097..5f1d8c6 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -144,6 +144,7 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
+      logger.atFine().log("Loading changes of project %s", key);
       try (ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index 4d0e056..535644d 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -17,7 +17,7 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.StringSerializer;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -35,7 +35,7 @@
       protected void configure() {
         persist(CACHE_NAME, String.class, TagSetHolder.class)
             .version(1)
-            .keySerializer(StringSerializer.INSTANCE)
+            .keySerializer(StringCacheSerializer.INSTANCE)
             .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
       }
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 916a64a..ce8814f 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -21,10 +21,10 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import java.io.IOException;
 import java.util.BitSet;
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 0790a36..4c0c035 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -18,9 +18,9 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
index 204a0d5..8c93833 100644
--- a/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/java/com/google/gerrit/server/git/TransferConfig.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.TimeUnit;
@@ -29,6 +28,7 @@
   private final PackConfig packConfig;
   private final long maxObjectSizeLimit;
   private final String maxObjectSizeLimitFormatted;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   @Inject
   TransferConfig(@GerritServerConfig Config cfg) {
@@ -43,6 +43,8 @@
                 TimeUnit.SECONDS);
     maxObjectSizeLimit = cfg.getLong("receive", "maxObjectSizeLimit", 0);
     maxObjectSizeLimitFormatted = cfg.getString("receive", null, "maxObjectSizeLimit");
+    inheritProjectMaxObjectSizeLimit =
+        cfg.getBoolean("receive", "inheritProjectMaxObjectSizeLimit", false);
 
     packConfig = new PackConfig();
     packConfig.setDeltaCompress(false);
@@ -67,13 +69,7 @@
     return maxObjectSizeLimitFormatted;
   }
 
-  public long getEffectiveMaxObjectSizeLimit(ProjectState p) {
-    long global = getMaxObjectSizeLimit();
-    long local = p.getMaxObjectSizeLimit();
-    if (global > 0 && local > 0) {
-      return Math.min(global, local);
-    }
-    // zero means "no limit", in this case the max is more limiting
-    return Math.max(global, local);
+  public boolean getInheritProjectMaxObjectSizeLimit() {
+    return inheritProjectMaxObjectSizeLimit;
   }
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 98a1823..a7336f0 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.CaseFormat;
 import com.google.common.base.Supplier;
 import com.google.common.flogger.FluentLogger;
@@ -24,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,6 +46,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
@@ -274,6 +279,75 @@
     }
 
     @Override
+    public void execute(Runnable command) {
+      super.execute(LoggingContext.copy(command));
+    }
+
+    @Override
+    public <T> Future<T> submit(Callable<T> task) {
+      return super.submit(LoggingContext.copy(task));
+    }
+
+    @Override
+    public <T> Future<T> submit(Runnable task, T result) {
+      return super.submit(LoggingContext.copy(task), result);
+    }
+
+    @Override
+    public Future<?> submit(Runnable task) {
+      return super.submit(LoggingContext.copy(task));
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+        throws InterruptedException {
+      return super.invokeAll(tasks.stream().map(LoggingContext::copy).collect(toList()));
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(
+        Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+        throws InterruptedException {
+      return super.invokeAll(
+          tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+    }
+
+    @Override
+    public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+        throws InterruptedException, ExecutionException {
+      return super.invokeAny(tasks.stream().map(LoggingContext::copy).collect(toList()));
+    }
+
+    @Override
+    public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+        throws InterruptedException, ExecutionException, TimeoutException {
+      return super.invokeAny(
+          tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+      return super.schedule(LoggingContext.copy(command), delay, unit);
+    }
+
+    @Override
+    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+      return super.schedule(LoggingContext.copy(callable), delay, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> scheduleAtFixedRate(
+        Runnable command, long initialDelay, long period, TimeUnit unit) {
+      return super.scheduleAtFixedRate(LoggingContext.copy(command), initialDelay, period, unit);
+    }
+
+    @Override
+    public ScheduledFuture<?> scheduleWithFixedDelay(
+        Runnable command, long initialDelay, long delay, TimeUnit unit) {
+      return super.scheduleWithFixedDelay(LoggingContext.copy(command), initialDelay, delay, unit);
+    }
+
+    @Override
     protected void terminated() {
       super.terminated();
       queues.remove(this);
@@ -367,6 +441,10 @@
 
         Task<V> task;
 
+        if (runnable instanceof LoggingContextAwareRunnable) {
+          runnable = ((LoggingContextAwareRunnable) runnable).unwrap();
+        }
+
         if (runnable instanceof ProjectRunnable) {
           task = new ProjectTask<>((ProjectRunnable) runnable, r, this, id);
         } else {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index ef25cd8..4c0378a 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.git.meta;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -122,10 +124,8 @@
     return buf.toString();
   }
 
-  protected static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
-    ArrayList<T> r = new ArrayList<>(m);
-    Collections.sort(r);
-    return r;
+  protected static <T extends Comparable<? super T>> ImmutableList<T> sort(Collection<T> m) {
+    return m.stream().sorted().collect(toImmutableList());
   }
 
   protected static String pad(int len, String src) {
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index da1f1ac..8b14177 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -17,7 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.LockFailureException;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -62,6 +64,8 @@
  * read from the repository, or format an update that can later be written back to the repository.
  */
 public abstract class VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /**
    * Path information that does not hold references to any repository data structures, allowing the
    * application to retain this object for long periods of time.
@@ -81,6 +85,7 @@
   /** The revision at which the data was loaded. Is null for data yet to be created. */
   @Nullable protected RevCommit revision;
 
+  protected Project.NameKey projectName;
   protected RevWalk rw;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
@@ -114,13 +119,15 @@
    * <p>The repository is not held after the call completes, allowing the application to retain this
    * object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param db repository to access.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db) throws IOException, ConfigInvalidException {
+  public void load(Project.NameKey projectName, Repository db)
+      throws IOException, ConfigInvalidException {
     Ref ref = db.getRefDatabase().exactRef(getRefName());
-    load(db, ref != null ? ref.getObjectId() : null);
+    load(projectName, db, ref != null ? ref.getObjectId() : null);
   }
 
   /**
@@ -133,15 +140,16 @@
    * <p>The repository is not held after the call completes, allowing the application to retain this
    * object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param db repository to access.
    * @param id revision to load.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db, @Nullable ObjectId id)
+  public void load(Project.NameKey projectName, Repository db, @Nullable ObjectId id)
       throws IOException, ConfigInvalidException {
     try (RevWalk walk = new RevWalk(db)) {
-      load(walk, id);
+      load(projectName, walk, id);
     }
   }
 
@@ -156,12 +164,15 @@
    * instance does not hold a reference to the walk or the repository after the call completes,
    * allowing the application to retain this object for long periods of time.
    *
+   * @param projectName the name of the project
    * @param walk open walk to access to access.
    * @param id revision to load.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
+  public void load(Project.NameKey projectName, RevWalk walk, ObjectId id)
+      throws IOException, ConfigInvalidException {
+    this.projectName = projectName;
     this.rw = walk;
     this.reader = walk.getObjectReader();
     try {
@@ -174,11 +185,11 @@
   }
 
   public void load(MetaDataUpdate update) throws IOException, ConfigInvalidException {
-    load(update.getRepository());
+    load(update.getProjectName(), update.getRepository());
   }
 
   public void load(MetaDataUpdate update, ObjectId id) throws IOException, ConfigInvalidException {
-    load(update.getRepository(), id);
+    load(update.getProjectName(), update.getRepository(), id);
   }
 
   /**
@@ -481,6 +492,9 @@
       return new byte[] {};
     }
 
+    logger.atFine().log(
+        "Read file '%s' from ref '%s' of project '%s' from revision '%s'",
+        fileName, getRefName(), projectName, revision.name());
     try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
       if (tw != null) {
         ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
@@ -553,6 +567,8 @@
   }
 
   protected void saveFile(String fileName, byte[] raw) throws IOException {
+    logger.atFine().log(
+        "Save file '%s' in ref '%s' of project '%s'", fileName, getRefName(), projectName);
     DirCacheEditor editor = newTree.editor();
     if (raw != null && 0 < raw.length) {
       final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index f0cc558..eb62d54 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -94,6 +94,7 @@
       // Don't expose the binding for ReceiveCommits.Factory. All callers should
       // be using AsyncReceiveCommits.Factory instead.
       install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
+      install(new FactoryModuleBuilder().build(BranchCommitValidator.Factory.class));
     }
 
     @Provides
@@ -224,7 +225,7 @@
     receivePack.setAllowNonFastForwards(true);
     receivePack.setRefLogIdent(user.newRefLogIdent());
     receivePack.setTimeout(transferConfig.getTimeout());
-    receivePack.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(projectState));
+    receivePack.setMaxObjectSizeLimit(projectState.getEffectiveMaxObjectSizeLimit().value);
     receivePack.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
     receivePack.setRefFilter(new ReceiveRefFilter());
     receivePack.setAllowPushOptions(true);
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index fddb9d6..f1c604b 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
new file mode 100644
index 0000000..24b6ab1
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.ssh.SshInfo;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Validates single commits for a branch. */
+public class BranchCommitValidator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final IdentifiedUser user;
+  private final PermissionBackend.ForProject permissions;
+  private final Project project;
+  private final Branch.NameKey branch;
+  private final SshInfo sshInfo;
+
+  interface Factory {
+    BranchCommitValidator create(
+        ProjectState projectState, Branch.NameKey branch, IdentifiedUser user);
+  }
+
+  @Inject
+  BranchCommitValidator(
+      CommitValidators.Factory commitValidatorsFactory,
+      PermissionBackend permissionBackend,
+      SshInfo sshInfo,
+      @Assisted ProjectState projectState,
+      @Assisted Branch.NameKey branch,
+      @Assisted IdentifiedUser user) {
+    this.sshInfo = sshInfo;
+    this.user = user;
+    this.branch = branch;
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    project = projectState.getProject();
+    permissions = permissionBackend.user(user).project(project.getNameKey());
+  }
+
+  /**
+   * Validates a single commit. If the commit does not validate, the command is rejected.
+   *
+   * @param objectReader the object reader to use.
+   * @param cmd the ReceiveCommand executing the push.
+   * @param commit the commit being validated.
+   * @param isMerged whether this is a merge commit created by magicBranch --merge option
+   * @param change the change for which this is a new patchset.
+   */
+  public boolean validCommit(
+      ObjectReader objectReader,
+      ReceiveCommand cmd,
+      RevCommit commit,
+      boolean isMerged,
+      List<ValidationMessage> messages,
+      NoteMap rejectCommits,
+      @Nullable Change change)
+      throws IOException {
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, branch.get(), objectReader, commit, user)) {
+      CommitValidators validators;
+      if (isMerged) {
+        validators =
+            commitValidatorsFactory.forMergedCommits(permissions, branch, user.asIdentifiedUser());
+      } else {
+        validators =
+            commitValidatorsFactory.forReceiveCommits(
+                permissions,
+                branch,
+                user.asIdentifiedUser(),
+                sshInfo,
+                rejectCommits,
+                receiveEvent.revWalk,
+                change);
+      }
+
+      for (CommitValidationMessage m : validators.validate(receiveEvent)) {
+        messages.add(
+            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.isError()));
+      }
+    } catch (CommitValidationException e) {
+      logger.atFine().log("Commit validation failed on %s", commit.name());
+      for (CommitValidationMessage m : e.getMessages()) {
+        // The non-error messages may contain background explanation for the
+        // fatal error, so have to preserve all messages.
+        messages.add(
+            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.isError()));
+      }
+      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage()));
+      return false;
+    }
+    return true;
+  }
+
+  private String messageForCommit(RevCommit c, String msg) {
+    return String.format("commit %s: %s", c.abbreviate(RevId.ABBREV_LEN).name(), msg);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 26795cb..4b475f9 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
@@ -38,8 +39,8 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
@@ -57,11 +58,11 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -81,7 +82,6 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -93,20 +93,15 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ChangeReportFormatter;
 import com.google.gerrit.server.git.GroupCollector;
@@ -116,13 +111,13 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -132,6 +127,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.CreateRefControl;
@@ -142,7 +138,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleException;
@@ -159,7 +154,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gwtorm.server.OrmException;
@@ -216,37 +210,19 @@
  *
  * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
  * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
+ * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
+ * result in updates to reviews, through the autoclose mechanism.
  */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private enum ReceiveError {
-    CONFIG_UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "Configuration changes can only be pushed by project owners\n"
-            + "who also have 'Push' rights on "
-            + RefNames.REFS_CONFIG),
-    UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "To push into this reference you need 'Push' rights."),
-    DELETE(
-        "You need 'Delete Reference' rights or 'Push' rights with the \n"
-            + "'Force Push' flag set to delete references."),
-    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-    CODE_REVIEW(
-        "You need 'Push' rights to upload code review requests.\n"
-            + "Verify that you are pushing to the right branch.");
-
-    private final String value;
-
-    ReceiveError(String value) {
-      this.value = value;
-    }
-
-    String get() {
-      return value;
-    }
-  }
+  private static final String CODE_REVIEW_ERROR =
+      "You need 'Push' rights to upload code review requests.\n"
+          + "Verify that you are pushing to the right branch.";
+  private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'";
+  private static final String CANNOT_DELETE_CONFIG =
+      "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
 
   interface Factory {
     ReceiveCommits create(
@@ -313,7 +289,6 @@
 
   // Injected fields.
   private final AccountResolver accountResolver;
-  private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeEditUtil editUtil;
@@ -322,7 +297,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeReportFormatter changeFormatter;
   private final CmdLineParser.Factory optionParserFactory;
-  private final CommitValidators.Factory commitValidatorsFactory;
+  private final BranchCommitValidator.Factory commitValidatorFactory;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -344,10 +319,8 @@
   private final ReviewDb db;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SshInfo sshInfo;
   private final SubmoduleOp.Factory subOpFactory;
   private final TagCache tagCache;
-  private final String canonicalWebUrl;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
@@ -357,39 +330,23 @@
   private final ReceivePack receivePack;
 
   // Immutable fields derived from constructor arguments.
+  private final boolean allowProjectOwnersToChangeParent;
   private final boolean allowPushToRefsChanges;
   private final LabelTypes labelTypes;
   private final NoteMap rejectCommits;
   private final PermissionBackend.ForProject permissions;
   private final Project project;
   private final Repository repo;
-  private final RequestId receiveId;
 
   // Collections populated during processing.
   private final List<UpdateGroupsRequest> updateGroups;
   private final List<ValidationMessage> messages;
-  private final ListMultimap<ReceiveError, String> errors;
+  /** Multimap of error text to refnames that produced that error. */
+  private final ListMultimap<String, String> errors;
+
   private final ListMultimap<String, String> pushOptions;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
 
-  @AutoValue
-  protected abstract static class ValidCommitKey {
-    abstract ObjectId getObjectId();
-
-    abstract Branch.NameKey getBranch();
-  }
-
-  private final Set<ValidCommitKey> validCommits;
-
-  /**
-   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
-   * provided over the wire.
-   *
-   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
-   * creating patch set refs.
-   */
-  private final List<ReceiveCommand> actualCommands;
-
   // Collections lazily populated during processing.
   private ListMultimap<Change.Id, Ref> refsByChange;
   private ListMultimap<ObjectId, Ref> refsById;
@@ -397,21 +354,15 @@
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
-  private String setFullNameTo;
   private boolean setChangeAsPrivate;
   private Optional<NoteDbPushOption> noteDbPushOption;
+  private Optional<String> tracePushOption;
 
-  // Handles for outputting back over the wire to the end user.
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
   private MessageSender messageSender;
 
   @Inject
   ReceiveCommits(
       AccountResolver accountResolver,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
       @GerritServerConfig Config cfg,
@@ -421,7 +372,7 @@
       ChangeNotes.Factory notesFactory,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
       CmdLineParser.Factory optionParserFactory,
-      CommitValidators.Factory commitValidatorsFactory,
+      BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
@@ -443,10 +394,8 @@
       ReviewDb db,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      SshInfo sshInfo,
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -456,12 +405,11 @@
       throws IOException {
     // Injected fields.
     this.accountResolver = accountResolver;
-    this.accountsUpdateProvider = accountsUpdateProvider;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeInserterFactory = changeInserterFactory;
-    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.commitValidatorFactory = commitValidatorFactory;
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.db = db;
@@ -487,7 +435,6 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.sshInfo = sshInfo;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
 
@@ -504,18 +451,17 @@
     project = projectState.getProject();
     labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
-    receiveId = RequestId.forProject(project.getNameKey());
     rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
-    this.canonicalWebUrl = canonicalWebUrl;
 
     // Collections populated during processing.
-    actualCommands = new ArrayList<>();
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
     messages = new ArrayList<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
     updateGroups = new ArrayList<>();
-    validCommits = new HashSet<>();
+
+    this.allowProjectOwnersToChangeParent =
+        cfg.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
 
     // Other settings populated during processing.
     newChangeForAllNotInTarget =
@@ -558,53 +504,176 @@
   }
 
   void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    newProgress = progress.beginSubTask("new", UNKNOWN);
-    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-    closeProgress = progress.beginSubTask("closed", UNKNOWN);
-    commandProgress = progress.beginSubTask("refs", UNKNOWN);
+    Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+    commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+    processCommandsUnsafe(commands, progress);
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() == NOT_ATTEMPTED) {
+        cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+      }
+    }
 
-    try {
-      parsePushOptions();
-      logDebug("Parsing %d commands", commands.size());
-      for (ReceiveCommand cmd : commands) {
-        if (!projectState.getProject().getState().permitsWrite()) {
+    // This sends error messages before the 'done' string of the progress monitor is sent.
+    // Currently, the test framework relies on this ordering to understand if pushes completed
+    // successfully.
+    sendErrorMessages();
+
+    commandProgress.end();
+    progress.end();
+  }
+
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    parsePushOptions();
+    try (TraceContext traceContext =
+        TraceContext.newTrace(
+            tracePushOption.isPresent(),
+            tracePushOption.orElse(null),
+            (tagName, traceId) -> addMessage(tagName + ": " + traceId))) {
+      traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
+
+      // Log the push options here, rather than in parsePushOptions(), so that they are included
+      // into the trace if tracing is enabled.
+      logger.atFine().log("push options: %s", receivePack.getPushOptions());
+
+      if (!projectState.getProject().getState().permitsWrite()) {
+        for (ReceiveCommand cmd : commands) {
           reject(cmd, "prohibited by Gerrit: project state does not permit write");
-          break;
         }
-        parseCommand(cmd);
+        return;
       }
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      for (ReceiveCommand cmd : actualCommands) {
-        if (cmd.getResult() == NOT_ATTEMPTED) {
-          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+
+      logger.atFine().log("Parsing %d commands", commands.size());
+
+      List<ReceiveCommand> magicCommands = new ArrayList<>();
+      List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
+      List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+      for (ReceiveCommand cmd : commands) {
+        if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+          magicCommands.add(cmd);
+        } else if (isDirectChangesPush(cmd.getRefName())) {
+          directPatchSetPushCommands.add(cmd);
+        } else {
+          regularCommands.add(cmd);
         }
       }
-      logError(String.format("Failed to process refs in %s", project.getName()), err);
-    }
 
-    List<CreateRequest> newChanges = Collections.emptyList();
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      newChanges = selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace(newChanges);
-    insertChangesAndPatchSets(newChanges);
-    newProgress.end();
-    replaceProgress.end();
+      int commandTypes =
+          (magicCommands.isEmpty() ? 0 : 1)
+              + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
+              + (regularCommands.isEmpty() ? 0 : 1);
 
+      if (commandTypes > 1) {
+        for (ReceiveCommand cmd : commands) {
+          if (cmd.getResult() == NOT_ATTEMPTED) {
+            cmd.setResult(REJECTED_OTHER_REASON, "cannot combine normal pushes and magic pushes");
+          }
+        }
+        return;
+      }
+
+      try {
+        if (!regularCommands.isEmpty()) {
+          handleRegularCommands(regularCommands, progress);
+          return;
+        }
+
+        for (ReceiveCommand cmd : directPatchSetPushCommands) {
+          parseDirectChangesPush(cmd);
+        }
+
+        boolean first = true;
+        for (ReceiveCommand cmd : magicCommands) {
+          if (first) {
+            parseMagicBranch(cmd);
+            first = false;
+          } else {
+            reject(cmd, "duplicate request");
+          }
+        }
+      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+        return;
+      }
+
+      Task newProgress = progress.beginSubTask("new", UNKNOWN);
+      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+      List<CreateRequest> newChanges = Collections.emptyList();
+      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+        newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+      }
+
+      // Commit validation has already happened, so any changes without Change-Id are for the
+      // deprecated feature.
+      warnAboutMissingChangeId(newChanges);
+      preparePatchSetsForReplace(newChanges);
+      insertChangesAndPatchSets(newChanges, replaceProgress);
+      newProgress.end();
+      replaceProgress.end();
+      queueSuccessMessages(newChanges);
+      refsPublishDeprecationWarning();
+    }
+  }
+
+  private void refsPublishDeprecationWarning() {
+    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+    if (magicBranch != null && magicBranch.publish) {
+      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
+    }
+  }
+
+  private void sendErrorMessages() {
     if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: %s", errors.keySet());
-      for (ReceiveError error : errors.keySet()) {
-        receivePack.sendMessage(buildError(error, errors.get(error)));
+      logger.atFine().log("Handling error conditions: %s", errors.keySet());
+      for (String error : errors.keySet()) {
+        receivePack.sendMessage("error: " + buildError(error, errors.get(error)));
       }
       receivePack.sendMessage(String.format("User: %s", user.getLoggableName()));
       receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
     }
+  }
+
+  private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
+      throws PermissionBackendException, IOException, NoSuchProjectException {
+    for (ReceiveCommand cmd : cmds) {
+      parseRegularCommand(cmd);
+    }
+
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRefLogMessage("push");
+
+      int added = 0;
+      for (ReceiveCommand cmd : cmds) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+          added++;
+        }
+      }
+      logger.atFine().log("Added %d additional ref updates", added);
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      for (ReceiveCommand cmd : cmds) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+      logger.atFine().withCause(e).log("update failed:");
+    }
 
     Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : actualCommands) {
+    for (ReceiveCommand c : cmds) {
       // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-      // should happen in this loop are things that can't happen within one BatchUpdate because they
-      // involve kicking off an additional BatchUpdate.
+      // should happen in this loop are things that can't happen within one BatchUpdate because
+      // they involve kicking off an additional BatchUpdate.
       if (c.getResult() != OK) {
         continue;
       }
@@ -613,7 +682,9 @@
           case CREATE:
           case UPDATE:
           case UPDATE_NONFASTFORWARD:
-            autoCloseChanges(c);
+            Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+            autoCloseChanges(c, closeProgress);
+            closeProgress.end();
             branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
             break;
 
@@ -626,26 +697,34 @@
     // Update superproject gitlinks if required.
     if (!branches.isEmpty()) {
       try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        orm.setContext(db, TimeUtil.nowTs(), user);
         SubmoduleOp op = subOpFactory.create(branches, orm);
         op.updateSuperProjects();
       } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
+        logger.atSevere().withCause(e).log("Can't update the superprojects");
       }
     }
-
-    // Update account info with details discovered during commit walking.
-    updateAccountInfo();
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages(newChanges);
   }
 
-  private void reportMessages(List<CreateRequest> newChanges) {
+  /** Appends messages for successful change creation/updates. */
+  private void queueSuccessMessages(List<CreateRequest> newChanges) {
     List<CreateRequest> created =
         newChanges.stream().filter(r -> r.change != null).collect(toList());
+    List<ReplaceRequest> updated =
+        replaceByChange
+            .values()
+            .stream()
+            .filter(r -> r.inputCommand.getResult() == OK)
+            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+            .collect(toList());
+
+    if (created.isEmpty() && updated.isEmpty()) {
+      return;
+    }
+
+    addMessage("");
+    addMessage("SUCCESS");
+
     if (!created.isEmpty()) {
       addMessage("");
       addMessage("New Changes:");
@@ -654,16 +733,8 @@
             changeFormatter.newChange(
                 ChangeReportFormatter.Input.builder().setChange(c.change).build()));
       }
-      addMessage("");
     }
 
-    List<ReplaceRequest> updated =
-        replaceByChange
-            .values()
-            .stream()
-            .filter(r -> r.inputCommand.getResult() == OK)
-            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
-            .collect(toList());
     if (!updated.isEmpty()) {
       addMessage("");
       addMessage("Updated Changes:");
@@ -689,7 +760,7 @@
             subject = receivePack.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
           } catch (IOException e) {
             // Log and fall back to original change subject
-            logWarn("failed to get subject for edit patch set", e);
+            logger.atWarning().withCause(e).log("failed to get subject for edit patch set");
             subject = u.notes.getChange().getSubject();
           }
         } else {
@@ -715,22 +786,16 @@
       }
       addMessage("");
     }
-
-    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
-    if (magicBranch != null && magicBranch.publish) {
-      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
-    }
   }
 
-  private void insertChangesAndPatchSets(List<CreateRequest> newChanges) {
+  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
     ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
     if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(
-          String.format(
-              "Skipping change updates on %s because ref update failed: %s %s",
-              project.getName(),
-              magicBranchCmd.getResult(),
-              Strings.nullToEmpty(magicBranchCmd.getMessage())));
+      logger.atWarning().log(
+          "Skipping change updates on %s because ref update failed: %s %s",
+          project.getName(),
+          magicBranchCmd.getResult(),
+          Strings.nullToEmpty(magicBranchCmd.getMessage()));
       return;
     }
 
@@ -741,26 +806,22 @@
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       bu.setRepository(repo, rw, ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
       bu.setRefLogMessage("push");
 
-      logDebug("Adding %d replace requests", newChanges.size());
+      logger.atFine().log("Adding %d replace requests", newChanges.size());
       for (ReplaceRequest replace : replaceByChange.values()) {
         replace.addOps(bu, replaceProgress);
       }
 
-      logDebug("Adding %d create requests", newChanges.size());
+      logger.atFine().log("Adding %d create requests", newChanges.size());
       for (CreateRequest create : newChanges) {
         create.addOps(bu);
       }
 
-      logDebug("Adding %d group update requests", newChanges.size());
+      logger.atFine().log("Adding %d group update requests", newChanges.size());
       updateGroups.forEach(r -> r.addOps(bu));
 
-      logDebug("Adding %d additional ref updates", actualCommands.size());
-      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
-
-      logDebug("Executing batch");
+      logger.atFine().log("Executing batch");
       try {
         bu.execute();
       } catch (UpdateException e) {
@@ -777,16 +838,17 @@
             replace.inputCommand.setResult(OK);
           }
         } else {
-          logDebug("Rejecting due to message from ReplaceOp");
+          logger.atFine().log("Rejecting due to message from ReplaceOp");
           reject(replace.inputCommand, rejectMessage);
         }
       }
 
     } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
+      addError(e.getMessage());
       reject(magicBranchCmd, "conflict");
     } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
+      logger.atSevere().withCause(err).log(
+          "Can't insert change/patch set for %s", project.getName());
       reject(magicBranchCmd, "internal server error: " + err.getMessage());
     }
 
@@ -794,7 +856,7 @@
       try {
         submit(newChanges, replaceByChange.values());
       } catch (ResourceConflictException e) {
-        addMessage(e.getMessage());
+        addError(e.getMessage());
         reject(magicBranchCmd, "conflict");
       } catch (RestApiException
           | OrmException
@@ -802,26 +864,21 @@
           | IOException
           | ConfigInvalidException
           | PermissionBackendException e) {
-        logError("Error submitting changes to " + project.getName(), e);
+        logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
         reject(magicBranchCmd, "error during submit");
       }
     }
   }
 
-  private String buildError(ReceiveError error, List<String> branches) {
+  private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
+      sb.append("branch ").append(branches.get(0)).append(":\n");
+      sb.append(error);
       return sb.toString();
     }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
+    sb.append("branches ").append(Joiner.on(", ").join(branches));
+    return sb.append(":\n").append(error).toString();
   }
 
   /** Parses push options specified as "git push -o OPTION" */
@@ -841,7 +898,7 @@
     List<String> noteDbValues = pushOptions.get("notedb");
     if (!noteDbValues.isEmpty()) {
       // These semantics for duplicates/errors are somewhat arbitrary and may not match e.g. the
-      // CommandLineParser behavior used by MagicBranchInput.
+      // CmdLineParser behavior used by MagicBranchInput.
       String value = noteDbValues.get(noteDbValues.size() - 1);
       noteDbPushOption = NoteDbPushOption.parse(value);
       if (!noteDbPushOption.isPresent()) {
@@ -850,13 +907,70 @@
     } else {
       noteDbPushOption = Optional.of(NoteDbPushOption.DISALLOW);
     }
+
+    List<String> traceValues = pushOptions.get("trace");
+    if (!traceValues.isEmpty()) {
+      String value = traceValues.get(traceValues.size() - 1);
+      tracePushOption = Optional.of(value);
+    } else {
+      tracePushOption = Optional.empty();
+    }
   }
 
-  private void parseCommand(ReceiveCommand cmd)
+  private static boolean isDirectChangesPush(String refname) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(refname);
+    return m.matches();
+  }
+
+  private void parseDirectChangesPush(ReceiveCommand cmd) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
+    checkArgument(m.matches());
+
+    if (allowPushToRefsChanges) {
+      // The referenced change must exist and must still be open.
+      Change.Id changeId = Change.Id.parse(m.group(1));
+      parseReplaceCommand(cmd, changeId);
+      messages.add(new ValidationMessage("warning: pushes to refs/changes are deprecated", false));
+    } else {
+      reject(cmd, "upload to refs/changes not allowed");
+    }
+  }
+
+  // Wrap ReceiveCommand so the progress counter works automatically.
+  private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
+    String refname = cmd.getRefName();
+
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+      refname = RefNames.refsUsers(user.getAccountId());
+      logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
+    }
+
+    // We must also update the original, because callers may inspect it afterwards to decide if
+    // the command went through or not.
+    return new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), refname, cmd.getType()) {
+      @Override
+      public void setResult(Result s, String m) {
+        if (getResult() == NOT_ATTEMPTED) { // Only report the progress update once.
+          progress.update(1);
+        }
+        // Counter intuitively, we don't check that results == NOT_ATTEMPTED here.
+        // This is so submit-on-push can still reject the update if the change is created
+        // successfully
+        // (status OK) but the submit failed (merge failed: REJECTED_OTHER_REASON).
+        super.setResult(s, m);
+        cmd.setResult(s, m);
+      }
+    };
+  }
+
+  /*
+   * Interpret a normal push.
+   */
+  private void parseRegularCommand(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     if (cmd.getResult() != NOT_ATTEMPTED) {
       // Already rejected by the core receive process.
-      logDebug("Already processed by core: %s %s", cmd.getResult(), cmd);
+      logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
       return;
     }
 
@@ -864,45 +978,12 @@
       reject(cmd, "not valid ref");
       return;
     }
-
-    if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-      parseMagicBranch(cmd);
-      return;
-    }
-
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
-      String newName = RefNames.refsUsers(user.getAccountId());
-      logDebug("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, newName);
-      final ReceiveCommand orgCmd = cmd;
-      cmd =
-          new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
-            @Override
-            public void setResult(Result s, String m) {
-              super.setResult(s, m);
-              orgCmd.setResult(s, m);
-            }
-          };
-    }
-
-    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
-    if (m.matches()) {
-      if (allowPushToRefsChanges) {
-        // The referenced change must exist and must still be open.
-        //
-        Change.Id changeId = Change.Id.parse(m.group(1));
-        parseReplaceCommand(cmd, changeId);
-      } else {
-        reject(cmd, "upload to refs/changes not allowed");
-      }
-      return;
-    }
-
     if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
       // Reject pushes to NoteDb refs without a special option and permission. Note that this
       // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
       // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
       // migration finishes.
-      logDebug(
+      logger.atFine().log(
           "%s NoteDb ref %s with %s=%s",
           cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
       if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
@@ -953,50 +1034,63 @@
     }
 
     if (isConfig(cmd)) {
-      logDebug("Processing %s command", cmd.getRefName());
-      try {
-        permissions.check(ProjectPermission.WRITE_CONFIG);
-      } catch (AuthException e) {
-        reject(
-            cmd,
-            String.format(
-                "must be either project owner or have %s permission",
-                ProjectPermission.WRITE_CONFIG.describeForException()));
-        return;
-      }
+      validateConfigPush(cmd);
+    }
+  }
 
-      switch (cmd.getType()) {
-        case CREATE:
-        case UPDATE:
-        case UPDATE_NONFASTFORWARD:
-          try {
-            ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-            cfg.load(receivePack.getRevWalk(), cmd.getNewId());
-            if (!cfg.getValidationErrors().isEmpty()) {
-              addError("Invalid project configuration:");
-              for (ValidationError err : cfg.getValidationErrors()) {
-                addError("  " + err.getMessage());
-              }
-              reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getLoggableName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName());
+  /** Validates a push to refs/meta/config, and reject the command if it fails. */
+  private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
+    logger.atFine().log("Processing %s command", cmd.getRefName());
+    try {
+      permissions.check(ProjectPermission.WRITE_CONFIG);
+    } catch (AuthException e) {
+      reject(
+          cmd,
+          String.format(
+              "must be either project owner or have %s permission",
+              ProjectPermission.WRITE_CONFIG.describeForException()));
+      return;
+    }
+
+    switch (cmd.getType()) {
+      case CREATE:
+      case UPDATE:
+      case UPDATE_NONFASTFORWARD:
+        try {
+          ProjectConfig cfg = new ProjectConfig(project.getNameKey());
+          cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
+          if (!cfg.getValidationErrors().isEmpty()) {
+            addError("Invalid project configuration:");
+            for (ValidationError err : cfg.getValidationErrors()) {
+              addError("  " + err.getMessage());
+            }
+            reject(cmd, "invalid project configuration");
+            logger.atSevere().log(
+                "User %s tried to push invalid project configuration %s for %s",
+                user.getLoggableName(), cmd.getNewId().name(), project.getName());
+            return;
+          }
+          Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+          Project.NameKey oldParent = project.getParent(allProjectsName);
+          if (oldParent == null) {
+            // update of the 'All-Projects' project
+            if (newParent != null) {
+              reject(cmd, "invalid project configuration: root project cannot have parent");
               return;
             }
-            Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-            Project.NameKey oldParent = project.getParent(allProjectsName);
-            if (oldParent == null) {
-              // update of the 'All-Projects' project
-              if (newParent != null) {
-                reject(cmd, "invalid project configuration: root project cannot have parent");
-                return;
-              }
-            } else {
-              if (!oldParent.equals(newParent)) {
+          } else {
+            if (!oldParent.equals(newParent)) {
+              if (allowProjectOwnersToChangeParent) {
+                try {
+                  permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .check(ProjectPermission.WRITE_CONFIG);
+                } catch (AuthException e) {
+                  reject(cmd, "invalid project configuration: only project owners can set parent");
+                  return;
+                }
+              } else {
                 try {
                   permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
                 } catch (AuthException e) {
@@ -1004,77 +1098,75 @@
                   return;
                 }
               }
-
-              if (projectCache.get(newParent) == null) {
-                reject(cmd, "invalid project configuration: parent does not exist");
-                return;
-              }
             }
 
-            for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-              PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-              ProjectConfigEntry configEntry = e.getProvider().get();
-              String value = pluginCfg.getString(e.getExportName());
-              String oldValue =
-                  projectState
-                      .getConfig()
-                      .getPluginConfig(e.getPluginName())
-                      .getString(e.getExportName());
-              if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                oldValue =
-                    Arrays.stream(
-                            projectState
-                                .getConfig()
-                                .getPluginConfig(e.getPluginName())
-                                .getStringList(e.getExportName()))
-                        .collect(joining("\n"));
-              }
-
-              if ((value == null ? oldValue != null : !value.equals(oldValue))
-                  && !configEntry.isEditable(projectState)) {
-                reject(
-                    cmd,
-                    String.format(
-                        "invalid project configuration: Not allowed to set parameter"
-                            + " '%s' of plugin '%s' on project '%s'.",
-                        e.getExportName(), e.getPluginName(), project.getName()));
-                continue;
-              }
-
-              if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                  && value != null
-                  && !configEntry.getPermittedValues().contains(value)) {
-                reject(
-                    cmd,
-                    String.format(
-                        "invalid project configuration: The value '%s' is "
-                            + "not permitted for parameter '%s' of plugin '%s'.",
-                        value, e.getExportName(), e.getPluginName()));
-              }
+            if (projectCache.get(newParent) == null) {
+              reject(cmd, "invalid project configuration: parent does not exist");
+              return;
             }
-          } catch (Exception e) {
-            reject(cmd, "invalid project configuration");
-            logError(
-                "User "
-                    + user.getLoggableName()
-                    + " tried to push invalid project configuration "
-                    + cmd.getNewId().name()
-                    + " for "
-                    + project.getName(),
-                e);
-            return;
           }
-          break;
-
-        case DELETE:
-          break;
-
-        default:
-          reject(
-              cmd,
-              "prohibited by Gerrit: don't know how to handle config update of type "
-                  + cmd.getType());
+          validatePluginConfig(cmd, cfg);
+        } catch (Exception e) {
+          reject(cmd, "invalid project configuration");
+          logger.atSevere().withCause(e).log(
+              "User %s tried to push invalid project configuration %s for %s",
+              user.getLoggableName(), cmd.getNewId().name(), project.getName());
           return;
+        }
+        break;
+
+      case DELETE:
+        break;
+
+      default:
+        reject(
+            cmd,
+            "prohibited by Gerrit: don't know how to handle config update of type "
+                + cmd.getType());
+    }
+  }
+
+  /**
+   * validates a push to refs/meta/config for plugin configuration, and rejects the push if it
+   * fails.
+   */
+  private void validatePluginConfig(ReceiveCommand cmd, ProjectConfig cfg) {
+    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+      PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+      ProjectConfigEntry configEntry = e.getProvider().get();
+      String value = pluginCfg.getString(e.getExportName());
+      String oldValue =
+          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+      if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+        oldValue =
+            Arrays.stream(
+                    projectState
+                        .getConfig()
+                        .getPluginConfig(e.getPluginName())
+                        .getStringList(e.getExportName()))
+                .collect(joining("\n"));
+      }
+
+      if ((value == null ? oldValue != null : !value.equals(oldValue))
+          && !configEntry.isEditable(projectState)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: Not allowed to set parameter"
+                    + " '%s' of plugin '%s' on project '%s'.",
+                e.getExportName(), e.getPluginName(), project.getName()));
+        continue;
+      }
+
+      if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+          && value != null
+          && !configEntry.getPermittedValues().contains(value)) {
+        reject(
+            cmd,
+            String.format(
+                "invalid project configuration: The value '%s' is "
+                    + "not permitted for parameter '%s' of plugin '%s'.",
+                value, e.getExportName(), e.getPluginName()));
       }
     }
   }
@@ -1085,13 +1177,12 @@
     try {
       obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Creating %s", cmd);
+    logger.atFine().log("Creating %s", cmd);
 
     if (isHead(cmd) && !isCommit(cmd)) {
       return;
@@ -1102,45 +1193,32 @@
       // Must pass explicit user instead of injecting a provider into CreateRefControl, since
       // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
       createRefControl.checkCreateRef(Providers.of(user), receivePack.getRepository(), branch, obj);
-    } catch (AuthException | ResourceConflictException denied) {
+    } catch (AuthException denied) {
+      rejectProhibited(cmd, denied);
+      return;
+    } catch (ResourceConflictException denied) {
       reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
       return;
     }
 
-    if (!validRefOperation(cmd)) {
-      // validRefOperation sets messages, so no need to provide more feedback.
-      return;
+    if (validRefOperation(cmd)) {
+      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
     }
-
-    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-    actualCommands.add(cmd);
   }
 
   private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Updating %s", cmd);
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
+    logger.atFine().log("Updating %s", cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
+    if (!err.isPresent()) {
       if (isHead(cmd) && !isCommit(cmd)) {
+        reject(cmd, "head must point to commit");
         return;
       }
-      if (!validRefOperation(cmd)) {
-        return;
+      if (validRefOperation(cmd)) {
+        validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
       }
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      actualCommands.add(cmd);
     } else {
-      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
-      } else {
-        errors.put(ReceiveError.UPDATE, cmd.getRefName());
-      }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
+      rejectProhibited(cmd, err.get());
     }
   }
 
@@ -1149,7 +1227,8 @@
     try {
       obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return false;
     }
@@ -1162,34 +1241,21 @@
   }
 
   private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Deleting %s", cmd);
+    logger.atFine().log("Deleting %s", cmd);
     if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
+      errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
       reject(cmd, "cannot delete changes");
-    } else if (canDelete(cmd)) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
-    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+    } else if (isConfigRef(cmd.getRefName())) {
+      errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
       reject(cmd, "cannot delete project configuration");
+    }
+
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
+
     } else {
-      errors.put(ReceiveError.DELETE, cmd.getRefName());
-      reject(cmd, "cannot delete references");
-    }
-  }
-
-  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    if (isConfigRef(cmd.getRefName())) {
-      // Never allow to delete the meta config branch.
-      return false;
-    }
-
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
-      return projectState.statePermitsWrite();
-    } catch (AuthException e) {
-      return false;
+      rejectProhibited(cmd, err.get());
     }
   }
 
@@ -1200,38 +1266,62 @@
     } catch (IncorrectObjectTypeException notCommit) {
       newObject = null;
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Rewinding %s", cmd);
+    logger.atFine().log("Rewinding %s", cmd);
 
     if (newObject != null) {
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
       if (cmd.getResult() != NOT_ATTEMPTED) {
         return;
       }
     }
 
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
     } else {
-      cmd.setResult(REJECTED_OTHER_REASON, "need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+      rejectProhibited(cmd, err.get());
     }
   }
 
+  private Optional<AuthException> checkRefPermission(ReceiveCommand cmd, RefPermission perm)
+      throws PermissionBackendException {
+    return checkRefPermission(permissions.ref(cmd.getRefName()), perm);
+  }
+
+  private Optional<AuthException> checkRefPermission(
+      PermissionBackend.ForRef forRef, RefPermission perm) throws PermissionBackendException {
+    try {
+      forRef.check(perm);
+      return Optional.empty();
+    } catch (AuthException e) {
+      return Optional.of(e);
+    }
+  }
+
+  private void rejectProhibited(ReceiveCommand cmd, AuthException err) {
+    err.getAdvice().ifPresent(a -> errors.put(a, cmd.getRefName()));
+    reject(cmd, prohibited(err, cmd.getRefName()));
+  }
+
+  private static String prohibited(AuthException e, String alreadyDisplayedResource) {
+    String msg = e.getMessage();
+    if (e instanceof PermissionDeniedException) {
+      PermissionDeniedException pde = (PermissionDeniedException) e;
+      if (pde.getResource().isPresent()
+          && pde.getResource().get().equals(alreadyDisplayedResource)) {
+        // Avoid repeating resource name if exactly the given name was already displayed by the
+        // generic git push machinery.
+        msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission();
+      }
+    }
+    return "prohibited by Gerrit: " + msg;
+  }
+
   static class MagicBranchInput {
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
@@ -1249,6 +1339,9 @@
     CmdLineParser cmdLineParser;
     Set<String> hashtags = new HashSet<>();
 
+    @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing")
+    String trace;
+
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
@@ -1501,14 +1594,8 @@
    * <p>Assumes we are handling a magic branch here.
    */
   private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
-
-    logDebug("Found magic branch %s", cmd.getRefName());
-    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
+    logger.atFine().log("Found magic branch %s", cmd.getRefName());
+    MagicBranchInput magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
     magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
     magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
 
@@ -1519,7 +1606,7 @@
       ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
     } catch (CmdLineException e) {
       if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
+        logger.atFine().log("Invalid branch syntax");
         reject(cmd, e.getMessage());
         return;
       }
@@ -1540,13 +1627,13 @@
       return;
     }
     if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling %s", RefNames.REFS_USERS_SELF);
+      logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
       ref = RefNames.refsUsers(user.getAccountId());
     }
     if (!receivePack.getAdvertisedRefs().containsKey(ref)
         && !ref.equals(readHEAD(repo))
         && !ref.equals(RefNames.REFS_CONFIG)) {
-      logDebug("Ref %s not found", ref);
+      logger.atFine().log("Ref %s not found", ref);
       if (ref.startsWith(Constants.R_HEADS)) {
         String n = ref.substring(Constants.R_HEADS.length());
         reject(cmd, "branch " + n + " not found");
@@ -1559,22 +1646,16 @@
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.perm = permissions.ref(ref);
 
-    try {
-      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
-    } catch (AuthException denied) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
-      reject(cmd, denied.getMessage());
-      return;
-    }
-    if (!projectState.statePermitsWrite()) {
-      reject(cmd, "project state does not permit write");
+    Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+    if (err.isPresent()) {
+      rejectProhibited(cmd, err.get());
       return;
     }
 
     // TODO(davido): Remove legacy support for drafts magic branch option
     // after repo-tool supports private and work-in-progress changes.
     if (magicBranch.draft && !receiveConfig.allowDrafts) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
+      errors.put(CODE_REVIEW_ERROR, ref);
       reject(cmd, "draft workflow is disabled");
       return;
     }
@@ -1607,10 +1688,9 @@
     }
 
     if (magicBranch.submit) {
-      try {
-        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
-      } catch (AuthException e) {
-        reject(cmd, e.getMessage());
+      err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
         return;
       }
     }
@@ -1619,10 +1699,10 @@
     RevCommit tip;
     try {
       tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("Tip of push: %s", tip.name());
+      logger.atFine().log("Tip of push: %s", tip.name());
     } catch (IOException ex) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", ex);
+      logger.atSevere().withCause(ex).log("Invalid pack upload; one or more objects weren't sent");
       return;
     }
 
@@ -1649,12 +1729,12 @@
           || magicBranch.base != null
           || magicBranch.merged
           || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
+        logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
         newChangeForAllNotInTarget = false;
       }
 
       if (magicBranch.base != null) {
-        logDebug("Handling %base: %s", magicBranch.base);
+        logger.atFine().log("Handling %%base: %s", magicBranch.base);
         magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
         for (ObjectId id : magicBranch.base) {
           try {
@@ -1666,7 +1746,8 @@
             reject(cmd, "base not found");
             return;
           } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            logger.atWarning().withCause(e).log(
+                "Project %s cannot read %s", project.getName(), id.name());
             reject(cmd, "internal server error");
             return;
           }
@@ -1677,31 +1758,40 @@
           return; // readBranchTip already rejected cmd.
         }
         magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
+        logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
       }
     } catch (IOException ex) {
-      logWarn(
-          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      logger.atWarning().withCause(ex).log(
+          "Error walking to %s in project %s", destBranch, project.getName());
       reject(cmd, "internal server error");
       return;
     }
 
-    // Validate that the new commits are connected with the target
-    // branch.  If they aren't, we want to abort. We do this check by
-    // looking to see if we can compute a merge base between the new
-    // commits and the target branch head.
-    //
+    if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
+      this.magicBranch = magicBranch;
+    }
+  }
+
+  // Validate that the new commits are connected with the target
+  // branch.  If they aren't, we want to abort. We do this check by
+  // looking to see if we can compute a merge base between the new
+  // commits and the target branch head.
+  private boolean validateConnected(ReceiveCommand cmd, Branch.NameKey dest, RevCommit tip) {
+    RevWalk walk = receivePack.getRevWalk();
     try {
-      Ref targetRef = receivePack.getAdvertisedRefs().get(magicBranch.dest.get());
+      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.get());
       if (targetRef == null || targetRef.getObjectId() == null) {
         // The destination branch does not yet exist. Assume the
         // history being sent for review will start it and thus
         // is "connected" to the branch.
-        logDebug("Branch is unborn");
-        return;
+        logger.atFine().log("Branch is unborn");
+
+        // This is not an error condition.
+        return true;
       }
+
       RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: %s", h.name());
+      logger.atFine().log("Current branch tip: %s", h.name());
       RevFilter oldRevFilter = walk.getRevFilter();
       try {
         walk.reset();
@@ -1709,16 +1799,19 @@
         walk.markStart(tip);
         walk.markStart(h);
         if (walk.next() == null) {
-          reject(magicBranch.cmd, "no common ancestry");
+          reject(cmd, "no common ancestry");
+          return false;
         }
       } finally {
         walk.reset();
         walk.setRevFilter(oldRevFilter);
       }
     } catch (IOException e) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+      cmd.setResult(REJECTED_MISSING_OBJECT);
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+      return false;
     }
+    return true;
   }
 
   private static String readHEAD(Repository repo) {
@@ -1741,7 +1834,7 @@
 
   // Handle an upload to refs/changes/XX/CHANGED-NUMBER.
   private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
+    logger.atFine().log("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
       reject(cmd, "invalid usage");
       return;
@@ -1750,9 +1843,9 @@
     RevCommit newCommit;
     try {
       newCommit = receivePack.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with %s", newCommit);
+      logger.atFine().log("Replacing with %s", newCommit);
     } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      logger.atSevere().withCause(e).log("Cannot parse %s as commit", cmd.getNewId().name());
       reject(cmd, "invalid commit");
       return;
     }
@@ -1761,11 +1854,11 @@
     try {
       changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
     } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
+      logger.atSevere().withCause(e).log("Change not found %s", changeId);
       reject(cmd, "change " + changeId + " not found");
       return;
     } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
+      logger.atSevere().withCause(e).log("Cannot lookup existing change %s", changeId);
       reject(cmd, "database error");
       return;
     }
@@ -1774,10 +1867,29 @@
       return;
     }
 
-    logDebug("Replacing change %s", changeEnt.getId());
-    requestReplace(cmd, true, changeEnt, newCommit);
+    BranchCommitValidator validator =
+        commitValidatorFactory.create(projectState, changeEnt.getDest(), user);
+    try {
+      if (validator.validCommit(
+          receivePack.getRevWalk().getObjectReader(),
+          cmd,
+          newCommit,
+          false,
+          messages,
+          rejectCommits,
+          changeEnt)) {
+        logger.atFine().log("Replacing change %s", changeEnt.getId());
+        requestReplace(cmd, true, changeEnt, newCommit);
+      }
+    } catch (IOException e) {
+      reject(cmd, "I/O exception validating commit");
+    }
   }
 
+  /**
+   * Add an update for an existing change. Returns true if it succeeded; rejects the command if it
+   * failed.
+   */
   private boolean requestReplace(
       ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
     if (change.getStatus().isClosed()) {
@@ -1797,14 +1909,34 @@
     return true;
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
+  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+    for (CreateRequest create : newChanges) {
+      try {
+        receivePack.getRevWalk().parseBody(create.commit);
+      } catch (IOException e) {
+        continue;
+      }
+      List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
+
+      if (idList.isEmpty()) {
+        messages.add(
+            new ValidationMessage("warning: pushing without Change-Id is deprecated", false));
+        break;
+      }
+    }
+  }
+
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+    logger.atFine().log("Finding new and replaced changes");
     List<CreateRequest> newChanges = new ArrayList<>();
 
     ListMultimap<ObjectId, Ref> existing = changeRefsById();
     GroupCollector groupCollector =
         GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
 
+    BranchCommitValidator validator =
+        commitValidatorFactory.create(projectState, magicBranch.dest, user);
+
     try {
       RevCommit start = setUpWalkForSelectingChanges();
       if (start == null) {
@@ -1874,13 +2006,15 @@
 
         List<String> idList = c.getFooterLines(CHANGE_ID);
         if (!idList.isEmpty()) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idList.get(idList.size() - 1).trim())));
+          pending.put(
+              c, lookupByChangeKey(c, new Change.Key(idList.get(idList.size() - 1).trim())));
         } else {
-          pending.put(c, new ChangeLookup(c));
+          pending.put(c, lookupByCommit(c));
         }
+
         int n = pending.size() + newChanges.size();
         if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("%d changes exceeds limit of %d", n, maxBatchChanges);
+          logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
           reject(
               magicBranch.cmd,
               "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
@@ -1899,12 +2033,19 @@
             continue;
           }
 
-          logDebug("Creating new change for %s even though it is already tracked", name);
+          logger.atFine().log("Creating new change for %s even though it is already tracked", name);
         }
 
-        if (!validCommit(receivePack.getRevWalk(), magicBranch.dest, magicBranch.cmd, c, null)) {
+        if (!validator.validCommit(
+            receivePack.getRevWalk().getObjectReader(),
+            magicBranch.cmd,
+            c,
+            magicBranch.merged,
+            messages,
+            rejectCommits,
+            null)) {
           // Not a change the user can propose? Abort as early as possible.
-          logDebug("Aborting early due to invalid commit");
+          logger.atFine().log("Aborting early due to invalid commit");
           return Collections.emptyList();
         }
 
@@ -1914,16 +2055,16 @@
               magicBranch.cmd,
               "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
                   + "to override please set the base manually");
-          logDebug("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
+          logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
           // TODO(dborowitz): Should we early return here?
         }
 
         if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get(), newProgress));
           continue;
         }
       }
-      logDebug(
+      logger.atFine().log(
           "Finished initial RevWalk with %d commits total: %d already"
               + " tracked, %d new changes with no Change-Id, and %d deferred"
               + " lookups",
@@ -1940,14 +2081,14 @@
         }
 
         if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id %s", p.changeKey);
+          logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
           reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           return Collections.emptyList();
         }
 
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
-          logDebug(
+          logger.atFine().log(
               "Multiple changes in branch %s with Change-Id %s: %s",
               magicBranch.dest,
               p.changeKey,
@@ -2003,9 +2144,9 @@
           }
           newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get(), newProgress));
       }
-      logDebug(
+      logger.atFine().log(
           "Finished deferred lookups with %d updates and %d new changes",
           replaceByChange.size(), newChanges.size());
     } catch (IOException e) {
@@ -2013,10 +2154,10 @@
       // identified the missing object earlier before we got control.
       //
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
       return Collections.emptyList();
     } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
+      logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
       reject(magicBranch.cmd, "database error");
       return Collections.emptyList();
     }
@@ -2044,9 +2185,9 @@
       for (UpdateGroupsRequest update : updateGroups) {
         update.groups = ImmutableList.copyOf((groups.get(update.commit)));
       }
-      logDebug("Finished updating groups from GroupCollector");
+      logger.atFine().log("Finished updating groups from GroupCollector");
     } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
+      logger.atSevere().withCause(e).log("Error collecting groups for changes");
       reject(magicBranch.cmd, "internal server error");
     }
     return newChanges;
@@ -2058,7 +2199,7 @@
           notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
       Change change = notes.getChange();
       if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change %s from existing refs.", change.getKey());
+        logger.atFine().log("Found change %s from existing refs.", change.getKey());
         // Reindex the change asynchronously, ignoring errors.
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
@@ -2079,7 +2220,7 @@
     if (magicBranch.baseCommit != null) {
       markExplicitBasesUninteresting();
     } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit %s uninteresting", start.name());
+      logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
       for (RevCommit c : start.getParents()) {
         rw.markUninteresting(c);
       }
@@ -2090,13 +2231,13 @@
   }
 
   private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
+    logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
     for (RevCommit c : magicBranch.baseCommit) {
       receivePack.getRevWalk().markUninteresting(c);
     }
     Ref targetRef = allRefs().get(magicBranch.dest.get());
     if (targetRef != null) {
-      logDebug(
+      logger.atFine().log(
           "Marking target ref %s (%s) uninteresting",
           magicBranch.dest.get(), targetRef.getObjectId().name());
       receivePack
@@ -2150,38 +2291,45 @@
           rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
           i++;
         } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+          logger.atWarning().withCause(e).log(
+              "Invalid ref %s in %s", ref.getName(), project.getName());
         }
       }
     }
-    logDebug("Marked %d heads as uninteresting", i);
+    logger.atFine().log("Marked %d heads as uninteresting", i);
   }
 
   private static boolean isValidChangeId(String idStr) {
     return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
   }
 
-  private class ChangeLookup {
+  private static class ChangeLookup {
     final RevCommit commit;
-    final Change.Key changeKey;
+
+    @Nullable final Change.Key changeKey;
     final List<ChangeData> destChanges;
 
-    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
-      commit = c;
-      changeKey = key;
-      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
-    }
-
-    ChangeLookup(RevCommit c) throws OrmException {
-      commit = c;
-      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
-      changeKey = null;
+    ChangeLookup(RevCommit c, @Nullable Change.Key key, final List<ChangeData> destChanges) {
+      this.commit = c;
+      this.changeKey = key;
+      this.destChanges = destChanges;
     }
   }
 
+  ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) throws OrmException {
+    return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+  }
+
+  ChangeLookup lookupByCommit(RevCommit c) throws OrmException {
+    return new ChangeLookup(
+        c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+  }
+
+  /** Represents a commit for which a Change should be created. */
   private class CreateRequest {
     final RevCommit commit;
-    private final String refName;
+    final Task progress;
+    final String refName;
 
     Change.Id changeId;
     ReceiveCommand cmd;
@@ -2190,9 +2338,10 @@
 
     Change change;
 
-    CreateRequest(RevCommit commit, String refName) {
+    CreateRequest(RevCommit commit, String refName, Task progress) {
       this.commit = commit;
       this.refName = refName;
+      this.progress = progress;
     }
 
     private void setChangeId(int id) {
@@ -2236,16 +2385,15 @@
         Account.Id me = user.getAccountId();
         List<FooterLine> footerLines = commit.getFooterLines();
         MailRecipients recipients = new MailRecipients();
-        Map<String, Short> approvals = new HashMap<>();
         checkNotNull(magicBranch);
         recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
+        Map<String, Short> approvals = magicBranch.labels;
         recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
         recipients.remove(me);
         StringBuilder msg =
             new StringBuilder(
                 ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
+                    psId.get(), approvals, Collections.emptyMap()));
         msg.append('.');
         if (!Strings.isNullOrEmpty(magicBranch.message)) {
           msg.append("\n").append(magicBranch.message);
@@ -2287,7 +2435,7 @@
                 return false;
               }
             });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+        bu.addOp(changeId, new ChangeProgressOp(progress));
       } catch (Exception e) {
         throw INSERT_EXCEPTION.apply(e);
       }
@@ -2308,7 +2456,7 @@
     Change tipChange = bySha.get(magicBranch.cmd.getNewId());
     checkNotNull(
         tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
+    logger.atFine().log(
         "Processing submit with tip change %s (%s)", tipChange.getId(), magicBranch.cmd.getNewId());
     try (MergeOp op = mergeOpProvider.get()) {
       op.merge(db, tipChange, user, false, new SubmitInput(), false);
@@ -2321,31 +2469,27 @@
       for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
         ReplaceRequest req = itr.next();
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
+          req.validateNewPatchSet();
         }
       }
     } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read database before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     } catch (IOException | PermissionBackendException err) {
-      logError(
-          String.format(
-              "Cannot read repository before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read repository before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     }
-    logDebug("Read %d changes to replace", replaceByChange.size());
+    logger.atFine().log("Read %d changes to replace", replaceByChange.size());
 
     if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
       // Cancel creations tied to refs/for/ or refs/drafts/ command.
@@ -2369,6 +2513,7 @@
     }
   }
 
+  /** Represents a commit that should be stored in a new patchset of an existing change. */
   private class ReplaceRequest {
     final Change.Id ontoChange;
     final ObjectId newCommitId;
@@ -2380,9 +2525,9 @@
     ReceiveCommand prev;
     ReceiveCommand cmd;
     PatchSetInfo info;
-    private PatchSet.Id priorPatchSet;
+    PatchSet.Id priorPatchSet;
     List<String> groups = ImmutableList.of();
-    private ReplaceOp replaceOp;
+    ReplaceOp replaceOp;
 
     ReplaceRequest(
         Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
@@ -2398,10 +2543,8 @@
               receivePack.getRevWalk().parseCommit(ref.getObjectId()),
               PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
+          logger.atWarning().withCause(err).log(
+              "Project %s contains invalid change ref %s", project.getName(), ref.getName());
         }
       }
     }
@@ -2417,18 +2560,46 @@
      *   <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk.
      * </ul>
      *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
      * @return whether the new commit is valid
      * @throws IOException
      * @throws OrmException
      * @throws PermissionBackendException
      */
-    boolean validate(boolean autoClose)
-        throws IOException, OrmException, PermissionBackendException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+    boolean validateNewPatchSet() throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb()) {
         return false;
-      } else if (notes == null) {
+      }
+      sameTreeWarning();
+
+      if (magicBranch != null) {
+        validateMagicBranchWipStatusChange();
+        if (inputCommand.getResult() != NOT_ATTEMPTED) {
+          return false;
+        }
+
+        if (magicBranch.edit || magicBranch.draft) {
+          return newEdit();
+        }
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    boolean validateNewPatchSetForAutoClose()
+        throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb()) {
+        return false;
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    /** Validates the new PS against permissions and notedb status. */
+    private boolean validateNewPatchSetNoteDb()
+        throws IOException, OrmException, PermissionBackendException {
+      if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
@@ -2441,7 +2612,6 @@
       }
 
       RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
 
       // Not allowed to create a new patch set if the current patch set is locked.
       if (psUtil.isPatchSetLocked(notes)) {
@@ -2456,10 +2626,6 @@
         return false;
       }
 
-      if (!projectState.statePermitsWrite()) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
       if (change.getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
@@ -2485,48 +2651,13 @@
         }
       }
 
-      if (!validCommit(
-          receivePack.getRevWalk(), change.getDest(), inputCommand, newCommit, change)) {
-        return false;
-      }
-      receivePack.getRevWalk().parseBody(priorCommit);
+      return true;
+    }
 
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
-      if (newCommit.getTree().equals(priorCommit.getTree())) {
-        boolean messageEq =
-            Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
-
-        if (messageEq && parentsEq && authorEq && !autoClose) {
-          addMessage(
-              String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
-        } else {
-          StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
-          msg.append(":");
-          msg.append(" no files changed");
-          if (!authorEq) {
-            msg.append(", author changed");
-          }
-          if (!messageEq) {
-            msg.append(", message updated");
-          }
-          if (!parentsEq) {
-            msg.append(", was rebased");
-          }
-          addMessage(msg.toString());
-        }
-      }
-
-      if (magicBranch != null
-          && (magicBranch.workInProgress || magicBranch.ready)
+    /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */
+    private void validateMagicBranchWipStatusChange() throws PermissionBackendException {
+      Change change = notes.getChange();
+      if ((magicBranch.workInProgress || magicBranch.ready)
           && magicBranch.workInProgress != change.isWorkInProgress()
           && !user.getAccountId().equals(change.getOwner())) {
         boolean hasWriteConfigPermission = false;
@@ -2542,27 +2673,59 @@
             permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
           } catch (AuthException e1) {
             reject(inputCommand, ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
-            return false;
           }
         }
       }
-
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        return newEdit();
-      }
-
-      newPatchSet();
-      return true;
     }
 
+    /** prints a warning if the new PS has the same tree as the previous commit. */
+    private void sameTreeWarning() throws IOException {
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+
+      if (newCommit.getTree().equals(priorCommit.getTree())) {
+        boolean messageEq =
+            Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
+        boolean parentsEq = parentsEqual(newCommit, priorCommit);
+        boolean authorEq = authorEqual(newCommit, priorCommit);
+        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
+
+        if (messageEq && parentsEq && authorEq) {
+          addMessage(
+              String.format(
+                  "warning: no changes between prior commit %s and new commit %s",
+                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+        } else {
+          StringBuilder msg = new StringBuilder();
+          msg.append("warning: ").append(reader.abbreviate(newCommit).name());
+          msg.append(":");
+          msg.append(" no files changed");
+          if (!authorEq) {
+            msg.append(", author changed");
+          }
+          if (!messageEq) {
+            msg.append(", message updated");
+          }
+          if (!parentsEq) {
+            msg.append(", was rebased");
+          }
+          addMessage(msg.toString());
+        }
+      }
+    }
+
+    /**
+     * Sets cmd and prev to the ReceiveCommands for change edits. Returns false if there was a
+     * failure.
+     */
     private boolean newEdit() {
       psId = notes.getChange().currentPatchSetId();
-      Optional<ChangeEdit> edit = null;
+      Optional<ChangeEdit> edit;
 
       try {
         edit = editUtil.byChange(notes, user);
       } catch (AuthException | IOException e) {
-        logError("Cannot retrieve edit", e);
+        logger.atSevere().withCause(e).log("Cannot retrieve edit");
         return false;
       }
 
@@ -2585,8 +2748,8 @@
       return true;
     }
 
+    /** Creates a ReceiveCommand for a new edit. */
     private void createEditCommand() {
-      // create new edit
       cmd =
           new ReceiveCommand(
               ObjectId.zeroId(),
@@ -2594,6 +2757,7 @@
               RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
     }
 
+    /** Updates 'this' to add a new patchset. */
     private void newPatchSet() throws IOException, OrmException {
       RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
       psId =
@@ -2644,8 +2808,8 @@
   }
 
   private class UpdateGroupsRequest {
-    private final PatchSet.Id psId;
-    private final RevCommit commit;
+    final PatchSet.Id psId;
+    final RevCommit commit;
     List<String> groups = ImmutableList.of();
 
     UpdateGroupsRequest(Ref ref, RevCommit commit) {
@@ -2680,7 +2844,7 @@
   }
 
   private class UpdateOneRefOp implements RepoOnlyOp {
-    private final ReceiveCommand cmd;
+    final ReceiveCommand cmd;
 
     private UpdateOneRefOp(ReceiveCommand cmd) {
       this.cmd = checkNotNull(cmd);
@@ -2695,11 +2859,11 @@
     public void postUpdate(Context ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-        logDebug("Updating tag cache on fast-forward of %s", cmd.getRefName());
+        logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
         tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
       }
       if (isConfig(cmd)) {
-        logDebug("Reloading project in cache");
+        logger.atFine().log("Reloading project in cache");
         try {
           projectCache.evict(project);
         } catch (IOException e) {
@@ -2708,7 +2872,7 @@
         }
         ProjectState ps = projectCache.get(project.getNameKey());
         try {
-          logDebug("Updating project description");
+          logger.atFine().log("Updating project description");
           repo.setGitwebDescription(ps.getProject().getDescription());
         } catch (IOException e) {
           logger.atWarning().withCause(e).log("cannot update description of %s", project.getName());
@@ -2793,6 +2957,8 @@
         && Objects.equals(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
   }
 
+  // Run RefValidators on the command. If any validator fails, the command status is set to
+  // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
@@ -2807,31 +2973,36 @@
     return true;
   }
 
-  private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
+  /**
+   * Validates the commits that a regular push brings in.
+   *
+   * <p>On validation failure, the command is rejected.
+   */
+  private void validateRegularPushCommits(Branch.NameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
     if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
             || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
         && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
-      try {
-        if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
-          throw new AuthException(
-              "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-
-        perm.check(RefPermission.SKIP_VALIDATION);
-        if (!Iterables.isEmpty(rejectCommits)) {
-          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-        logDebug("Short-circuiting new commit validation");
-      } catch (AuthException denied) {
-        reject(cmd, denied.getMessage());
+      if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+        reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+        return;
       }
+
+      Optional<AuthException> err =
+          checkRefPermission(permissions.ref(branch.get()), RefPermission.SKIP_VALIDATION);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
+        return;
+      }
+      if (!Iterables.isEmpty(rejectCommits)) {
+        reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+      }
+      logger.atFine().log("Short-circuiting new commit validation");
       return;
     }
 
-    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
+    BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
     RevWalk walk = receivePack.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
@@ -2847,79 +3018,35 @@
       int n = 0;
       for (RevCommit c; (c = walk.next()) != null; ) {
         if (++n > limit) {
-          logDebug("Number of new commits exceeds limit of %d", limit);
-          addMessage(
+          logger.atFine().log("Number of new commits exceeds limit of %d", limit);
+          reject(
+              cmd,
               String.format(
-                  "Cannot push more than %d commits to %s without %s option "
-                      + "(see %sDocumentation/user-upload.html#skip_validation for details)",
-                  limit, branch.get(), PUSH_OPTION_SKIP_VALIDATION, canonicalWebUrl));
-          reject(cmd, "too many commits");
+                  "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
           return;
         }
         if (existing.keySet().contains(c)) {
           continue;
-        } else if (!validCommit(walk, branch, cmd, c, null)) {
+        }
+
+        if (!validator.validCommit(
+            walk.getObjectReader(), cmd, c, false, messages, rejectCommits, null)) {
           break;
         }
-
-        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          logDebug("Will update full name of caller");
-          setFullNameTo = c.getCommitterIdent().getName();
-          missingFullName = false;
-        }
       }
-      logDebug("Validated %d new commits", n);
+      logger.atFine().log("Validated %d new commits", n);
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
+      logger.atSevere().withCause(err).log("Invalid pack upload; one or more objects weren't sent");
     }
   }
 
-  private boolean validCommit(
-      RevWalk rw, Branch.NameKey branch, ReceiveCommand cmd, ObjectId id, @Nullable Change change)
-      throws IOException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
-
-    ValidCommitKey key = new AutoValue_ReceiveCommits_ValidCommitKey(id.copy(), branch);
-    if (validCommits.contains(key)) {
-      return true;
-    }
-
-    RevCommit c = rw.parseCommit(id);
-    rw.parseBody(c);
-
-    try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
-      boolean isMerged =
-          magicBranch != null
-              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-              && magicBranch.merged;
-      CommitValidators validators =
-          isMerged
-              ? commitValidatorsFactory.forMergedCommits(
-                  project.getNameKey(), perm, user.asIdentifiedUser())
-              : commitValidatorsFactory.forReceiveCommits(
-                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw, change);
-      messages.addAll(validators.validate(receiveEvent));
-    } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on %s", c.name());
-      messages.addAll(e.getMessages());
-      reject(cmd, e.getMessage());
-      return false;
-    }
-    validCommits.add(key);
-    return true;
-  }
-
-  private void autoCloseChanges(ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
+  private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
+    logger.atFine().log("Starting auto-closing of changes");
     String refName = cmd.getRefName();
-    checkState(
-        !MagicBranch.isMagicBranch(refName),
-        "shouldn't be auto-closing changes on magic branch %s",
-        refName);
+
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // insertChangesAndPatchSets.
+    // handleRegularCommands
     try {
       retryHelper.execute(
           updateFactory -> {
@@ -2929,7 +3056,6 @@
                 ObjectReader reader = ins.newReader();
                 RevWalk rw = new RevWalk(reader)) {
               bu.setRepository(repo, rw, ins).updateChangesInParallel();
-              bu.setRequestId(receiveId);
               // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
               RevCommit newTip = rw.parseCommit(cmd.getNewId());
@@ -2983,8 +3109,8 @@
 
               for (ReplaceRequest req : replaceAndClose) {
                 Change.Id id = req.notes.getChangeId();
-                if (!req.validate(true)) {
-                  logDebug("Not closing %s because validation failed", id);
+                if (!req.validateNewPatchSetForAutoClose()) {
+                  logger.atFine().log("Not closing %s because validation failed", id);
                   continue;
                 }
                 req.addOps(bu, null);
@@ -2993,15 +3119,15 @@
                     mergedByPushOpFactory
                         .create(requestScopePropagator, req.psId, refName)
                         .setPatchSetProvider(req.replaceOp::getPatchSet));
-                bu.addOp(id, new ChangeProgressOp(closeProgress));
+                bu.addOp(id, new ChangeProgressOp(progress));
               }
 
-              logDebug(
+              logger.atFine().log(
                   "Auto-closing %s changes with existing patch sets and %s with new patch sets",
                   existingPatchSets, newPatchSets);
               bu.execute();
             } catch (IOException | OrmException | PermissionBackendException e) {
-              logError("Failed to auto-close changes", e);
+              logger.atSevere().withCause(e).log("Failed to auto-close changes");
             }
             return null;
           },
@@ -3011,9 +3137,9 @@
               .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
               .build());
     } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
+      logger.atSevere().withCause(e).log("Can't insert patchset");
     } catch (UpdateException e) {
-      logError("Failed to auto-close changes", e);
+      logger.atSevere().withCause(e).log("Failed to auto-close changes");
     }
   }
 
@@ -3035,31 +3161,6 @@
     }
   }
 
-  private void updateAccountInfo() {
-    if (setFullNameTo == null) {
-      return;
-    }
-    logDebug("Updating full name of caller");
-    try {
-      Optional<AccountState> accountState =
-          accountsUpdateProvider
-              .get()
-              .update(
-                  "Set Full Name on Receive Commits",
-                  user.getAccountId(),
-                  (a, u) -> {
-                    if (Strings.isNullOrEmpty(a.getAccount().getFullName())) {
-                      u.setFullName(setFullNameTo);
-                    }
-                  });
-      accountState
-          .map(AccountState::getAccount)
-          .ifPresent(a -> user.getAccount().setFullName(a.getFullName()));
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      logWarn("Failed to update full name of caller", e);
-    }
-  }
-
   private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
       throws OrmException {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
@@ -3080,11 +3181,8 @@
     return allRefsWatcher.getAllRefs();
   }
 
-  private void reject(@Nullable ReceiveCommand cmd, String why) {
-    if (cmd != null) {
-      cmd.setResult(REJECTED_OTHER_REASON, why);
-      commandProgress.update(1);
-    }
+  private static void reject(ReceiveCommand cmd, String why) {
+    cmd.setResult(REJECTED_OTHER_REASON, why);
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
@@ -3094,46 +3192,4 @@
   private static boolean isConfig(ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(receiveId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(receiveId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(receiveId + msg, arg1, arg2);
-  }
-
-  private void logDebug(
-      String msg, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3);
-  }
-
-  private void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logWarn(msg, null);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 5462631..e9fe562 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountConfig;
 import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.inject.Inject;
@@ -38,21 +39,30 @@
 public class AccountValidator {
 
   private final Provider<IdentifiedUser> self;
+  private final AllUsersName allUsersName;
   private final OutgoingEmailValidator emailValidator;
 
   @Inject
-  public AccountValidator(Provider<IdentifiedUser> self, OutgoingEmailValidator emailValidator) {
+  public AccountValidator(
+      Provider<IdentifiedUser> self,
+      AllUsersName allUsersName,
+      OutgoingEmailValidator emailValidator) {
     this.self = self;
+    this.allUsersName = allUsersName;
     this.emailValidator = emailValidator;
   }
 
   public List<String> validate(
-      Account.Id accountId, Repository repo, RevWalk rw, @Nullable ObjectId oldId, ObjectId newId)
+      Account.Id accountId,
+      Repository allUsersRepo,
+      RevWalk rw,
+      @Nullable ObjectId oldId,
+      ObjectId newId)
       throws IOException {
     Optional<Account> oldAccount = Optional.empty();
     if (oldId != null && !ObjectId.zeroId().equals(oldId)) {
       try {
-        oldAccount = loadAccount(accountId, repo, rw, oldId, null);
+        oldAccount = loadAccount(accountId, allUsersRepo, rw, oldId, null);
       } catch (ConfigInvalidException e) {
         // ignore, maybe the new commit is repairing it now
       }
@@ -61,7 +71,7 @@
     List<String> messages = new ArrayList<>();
     Optional<Account> newAccount;
     try {
-      newAccount = loadAccount(accountId, repo, rw, newId, messages);
+      newAccount = loadAccount(accountId, allUsersRepo, rw, newId, messages);
     } catch (ConfigInvalidException e) {
       return ImmutableList.of(
           String.format(
@@ -94,14 +104,14 @@
 
   private Optional<Account> loadAccount(
       Account.Id accountId,
-      Repository repo,
+      Repository allUsersRepo,
       RevWalk rw,
       ObjectId commit,
       @Nullable List<String> messages)
       throws IOException, ConfigInvalidException {
     rw.reset();
-    AccountConfig accountConfig = new AccountConfig(accountId, repo);
-    accountConfig.load(rw, commit);
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo);
+    accountConfig.load(allUsersName, rw, commit);
     if (messages != null) {
       messages.addAll(
           accountConfig
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 1cf71c0..7930fe8 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -35,9 +35,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Branch.NameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -46,11 +44,9 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
@@ -79,6 +75,9 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 
+/**
+ * Represents a list of CommitValidationListeners to run for a push to one branch of one project.
+ */
 public class CommitValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -124,15 +123,15 @@
     }
 
     public CommitValidators forReceiveCommits(
-        PermissionBackend.ForRef perm,
+        PermissionBackend.ForProject forProject,
         Branch.NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
-        Repository repo,
+        NoteMap rejectCommits,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -158,13 +157,14 @@
     }
 
     public CommitValidators forGerritCommits(
-        ForRef perm,
+        PermissionBackend.ForProject forProject,
         NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -188,7 +188,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        Project.NameKey project, PermissionBackend.ForRef perm, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, Branch.NameKey branch, IdentifiedUser user)
         throws IOException {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
@@ -203,10 +203,11 @@
       //    discuss what to do about it.
       //  - Plugin validators may do things like require certain commit message
       //    formats, so we play it safe and exclude them.
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
-              new ProjectStateValidationListener(projectCache.checkedGet(project)),
+              new ProjectStateValidationListener(projectCache.checkedGet(branch.getParentKey())),
               new AuthorUploaderValidator(user, perm, canonicalWebUrl),
               new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
     }
@@ -237,18 +238,17 @@
 
   public static class ChangeIdValidator implements CommitValidationListener {
     private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
-    private static final String MISSING_CHANGE_ID_MSG =
-        "[%s] missing Change-Id in commit message footer";
+    private static final String MISSING_CHANGE_ID_MSG = "missing Change-Id in message footer";
     private static final String MISSING_SUBJECT_MSG =
-        "[%s] missing subject; Change-Id must be in commit message footer";
+        "missing subject; Change-Id must be in message footer";
     private static final String MULTIPLE_CHANGE_ID_MSG =
-        "[%s] multiple Change-Id lines in commit message footer";
+        "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
-        "[%s] invalid Change-Id line format in commit message footer";
+        "invalid Change-Id line format in message footer";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
-        "[%s] Change-Id in commit message footer does not match Change-Id of target change";
+        "Change-Id in message footer does not match Change-Id of target change";
 
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
@@ -283,35 +283,29 @@
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new ArrayList<>();
       List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
-      String sha1 = commit.abbreviate(RevId.ABBREV_LEN).name();
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
         if (shortMsg.startsWith(CHANGE_ID_PREFIX)
             && CHANGE_ID.matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()).matches()) {
-          String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
-          throw new CommitValidationException(errMsg);
+          throw new CommitValidationException(MISSING_SUBJECT_MSG);
         }
         if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) {
-          String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
-          throw new CommitValidationException(errMsg, messages);
+          messages.add(getMissingChangeIdErrorMsg(MISSING_CHANGE_ID_MSG, commit));
+          throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
         }
       } else if (idList.size() > 1) {
-        String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1);
-        throw new CommitValidationException(errMsg, messages);
+        throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
         String v = idList.get(idList.size() - 1).trim();
         // Reject Change-Ids with wrong format and invalid placeholder ID from
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
-          String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
-          throw new CommitValidationException(errMsg, messages);
+          messages.add(getMissingChangeIdErrorMsg(INVALID_CHANGE_ID_MSG, receiveEvent.commit));
+          throw new CommitValidationException(INVALID_CHANGE_ID_MSG, messages);
         }
         if (change != null && !v.equals(change.getKey().get())) {
-          String errMsg = String.format(CHANGE_ID_MISMATCH_MSG, sha1);
-          throw new CommitValidationException(errMsg);
+          throw new CommitValidationException(CHANGE_ID_MISMATCH_MSG);
         }
       }
 
@@ -539,7 +533,7 @@
           perm.check(RefPermission.FORGE_COMMITTER);
         } catch (AuthException denied) {
           throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in commit message footer");
+              "not Signed-off-by author/committer/uploader in message footer");
         } catch (PermissionBackendException e) {
           logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
           throw new CommitValidationException("internal auth error");
@@ -574,8 +568,7 @@
         return Collections.emptyList();
       } catch (AuthException e) {
         throw new CommitValidationException(
-            "invalid author",
-            invalidEmail(receiveEvent.commit, "author", author, user, canonicalWebUrl));
+            "invalid author", invalidEmail("author", author, user, canonicalWebUrl));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -608,8 +601,7 @@
         return Collections.emptyList();
       } catch (AuthException e) {
         throw new CommitValidationException(
-            "invalid committer",
-            invalidEmail(receiveEvent.commit, "committer", committer, user, canonicalWebUrl));
+            "invalid committer", invalidEmail("committer", committer, user, canonicalWebUrl));
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -829,42 +821,30 @@
   }
 
   private static CommitValidationMessage invalidEmail(
-      RevCommit c,
-      String type,
-      PersonIdent who,
-      IdentifiedUser currentUser,
-      String canonicalWebUrl) {
+      String type, PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
     StringBuilder sb = new StringBuilder();
-    sb.append("\n");
-    sb.append("ERROR:  In commit ").append(c.name()).append("\n");
-    sb.append("ERROR:  ")
-        .append(type)
-        .append(" email address ")
+
+    sb.append("email address ")
         .append(who.getEmailAddress())
-        .append("\n");
-    sb.append("ERROR:  does not match your user account and you have no 'forge ")
+        .append(" is not registered in your account, and you lack 'forge ")
         .append(type)
         .append("' permission.\n");
-    sb.append("ERROR:\n");
+
     if (currentUser.getEmailAddresses().isEmpty()) {
-      sb.append("ERROR:  You have not registered any email addresses.\n");
+      sb.append("You have not registered any email addresses.\n");
     } else {
-      sb.append("ERROR:  The following addresses are currently registered:\n");
+      sb.append("The following addresses are currently registered:\n");
       for (String address : currentUser.getEmailAddresses()) {
-        sb.append("ERROR:    ").append(address).append("\n");
+        sb.append("   ").append(address).append("\n");
       }
     }
-    sb.append("ERROR:\n");
+
     if (canonicalWebUrl != null) {
-      sb.append("ERROR:  To register an email address, please visit:\n");
-      sb.append("ERROR:  ")
-          .append(canonicalWebUrl)
-          .append("#")
-          .append(PageLinks.SETTINGS_CONTACT)
-          .append("\n");
+      sb.append("To register an email address, visit:\n");
+      sb.append(canonicalWebUrl).append("#").append(PageLinks.SETTINGS_CONTACT).append("\n");
     }
     sb.append("\n");
-    return new CommitValidationMessage(sb.toString(), false);
+    return new CommitValidationMessage(sb.toString(), true);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 94d9996..c4df4dd 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -32,12 +32,14 @@
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -48,6 +50,7 @@
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -114,12 +117,17 @@
         "Change contains a project configuration that changes the parent"
             + " project.\n"
             + "The change must be submitted by a Gerrit administrator.";
+    private static final String SET_BY_OWNER =
+        "Change contains a project configuration that changes the parent"
+            + " project.\n"
+            + "The change must be submitted by a Gerrit administrator or the project owner.";
 
     private final AllProjectsName allProjectsName;
     private final AllUsersName allUsersName;
     private final ProjectCache projectCache;
     private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+    private final boolean allowProjectOwnersToChangeParent;
 
     public interface Factory {
       ProjectConfigValidator create();
@@ -131,12 +139,15 @@
         AllUsersName allUsersName,
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
-        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+        @GerritServerConfig Config config) {
       this.allProjectsName = allProjectsName;
       this.allUsersName = allUsersName;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
+      this.allowProjectOwnersToChangeParent =
+          config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
     }
 
     @Override
@@ -152,7 +163,7 @@
         final Project.NameKey newParent;
         try {
           ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
-          cfg.load(repo, commit);
+          cfg.load(destProject.getNameKey(), repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
           if (oldParent == null) {
@@ -162,13 +173,27 @@
             }
           } else {
             if (!oldParent.equals(newParent)) {
-              try {
-                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-              } catch (AuthException e) {
-                throw new MergeValidationException(SET_BY_ADMIN);
-              } catch (PermissionBackendException e) {
-                logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
-                throw new MergeValidationException("validation unavailable");
+              if (!allowProjectOwnersToChangeParent) {
+                try {
+                  permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+                } catch (AuthException e) {
+                  throw new MergeValidationException(SET_BY_ADMIN);
+                } catch (PermissionBackendException e) {
+                  logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
+                  throw new MergeValidationException("validation unavailable");
+                }
+              } else {
+                try {
+                  permissionBackend
+                      .user(caller)
+                      .project(destProject.getNameKey())
+                      .check(ProjectPermission.WRITE_CONFIG);
+                } catch (AuthException e) {
+                  throw new MergeValidationException(SET_BY_OWNER);
+                } catch (PermissionBackendException e) {
+                  logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
+                  throw new MergeValidationException("validation unavailable");
+                }
               }
               if (allUsersName.equals(destProject.getNameKey())
                   && !allProjectsName.equals(newParent)) {
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index c6d1a6f..61fdb60 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.NoteDbUtil;
 import com.google.inject.Inject;
@@ -49,10 +50,12 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String serverId;
+  private final AllUsersName allUsersName;
 
   @Inject
-  public AuditLogReader(@GerritServerId String serverId) {
+  public AuditLogReader(@GerritServerId String serverId, AllUsersName allUsersName) {
     this.serverId = serverId;
+    this.allUsersName = allUsersName;
   }
 
   // Having separate methods for reading the two types of audit records mirrors the split in
@@ -60,8 +63,8 @@
   // revisit this, e.g. to do only a single walk, or even change the record types.
 
   public ImmutableList<AccountGroupMemberAudit> getMembersAudit(
-      Repository repo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
-    return getMembersAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+      Repository allUsersRepo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
+    return getMembersAudit(getGroupId(allUsersRepo, uuid), parseCommits(allUsersRepo, uuid));
   }
 
   private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
@@ -211,10 +214,13 @@
     }
   }
 
-  private AccountGroup.Id getGroupId(Repository repo, AccountGroup.UUID uuid)
+  private AccountGroup.Id getGroupId(Repository allUsersRepo, AccountGroup.UUID uuid)
       throws ConfigInvalidException, IOException {
     // TODO(dborowitz): This re-walks all commits just to find createdOn, which we don't need.
-    return GroupConfig.loadForGroup(repo, uuid).getLoadedGroup().get().getId();
+    return GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid)
+        .getLoadedGroup()
+        .get()
+        .getId();
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 62f87c6..2fae4cc 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -51,9 +52,10 @@
  * A representation of a group in NoteDb.
  *
  * <p>Groups in NoteDb can be created by following the descriptions of {@link
- * #createForNewGroup(Repository, InternalGroupCreation)}. For reading groups from NoteDb or
- * updating them, refer to {@link #loadForGroup(Repository, AccountGroup.UUID)} or {@link
- * #loadForGroupSnapshot(Repository, AccountGroup.UUID, ObjectId)}.
+ * #createForNewGroup(Project.NameKey, Repository, InternalGroupCreation)}. For reading groups from
+ * NoteDb or updating them, refer to {@link #loadForGroup(Project.NameKey, Repository,
+ * AccountGroup.UUID)} or {@link #loadForGroupSnapshot(Project.NameKey, Repository,
+ * AccountGroup.UUID, ObjectId)}.
  *
  * <p><strong>Note: </strong>Any modification (group creation or update) only becomes permanent (and
  * hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
@@ -100,6 +102,7 @@
    * <p><strong>Note: </strong>The returned {@code GroupConfig} has to be committed via {@link
    * #commit(MetaDataUpdate)} in order to create the group for real.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupCreation an {@code InternalGroupCreation} specifying all properties which are
    *     required for a new group
@@ -110,10 +113,10 @@
    * @throws OrmDuplicateKeyException if a group with the same UUID already exists
    */
   public static GroupConfig createForNewGroup(
-      Repository repository, InternalGroupCreation groupCreation)
+      Project.NameKey projectName, Repository repository, InternalGroupCreation groupCreation)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
     GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID());
-    groupConfig.load(repository);
+    groupConfig.load(projectName, repository);
     groupConfig.setGroupCreation(groupCreation);
     return groupConfig;
   }
@@ -131,27 +134,30 @@
    * {@code InternalGroupUpdate} via {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)}
    * and committing the {@code GroupConfig} via {@link #commit(MetaDataUpdate)}.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @return a {@code GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
-  public static GroupConfig loadForGroup(Repository repository, AccountGroup.UUID groupUuid)
+  public static GroupConfig loadForGroup(
+      Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
-    groupConfig.load(repository);
+    groupConfig.load(projectName, repository);
     return groupConfig;
   }
 
   /**
    * Creates a {@code GroupConfig} for an existing group at a specific revision of the repository.
    *
-   * <p>This method behaves nearly the same as {@link #loadForGroup(Repository, AccountGroup.UUID)}.
-   * The only difference is that {@link #loadForGroup(Repository, AccountGroup.UUID)} loads the
-   * group from the current state of the repository whereas this method loads the group at a
-   * specific (maybe past) revision.
+   * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
+   * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
+   * Repository, AccountGroup.UUID)} loads the group from the current state of the repository
+   * whereas this method loads the group at a specific (maybe past) revision.
    *
+   * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @param commitId the revision of the repository at which the group should be loaded
@@ -160,10 +166,13 @@
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
   public static GroupConfig loadForGroupSnapshot(
-      Repository repository, AccountGroup.UUID groupUuid, ObjectId commitId)
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      ObjectId commitId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
-    groupConfig.load(repository, commitId);
+    groupConfig.load(projectName, repository, commitId);
     return groupConfig;
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 1b74241..440bc42 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -25,10 +25,12 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Multiset;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -62,12 +64,12 @@
  * map of name/UUID pairs and manage it with this class.
  *
  * <p>To claim the name for a new group, create an instance of {@code GroupNameNotes} via {@link
- * #forNewGroup(Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call {@link
- * #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it. For
- * renaming, call {@link #forRename(Repository, AccountGroup.UUID, AccountGroup.NameKey,
- * AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}. Both times, the
- * creation of the {@code GroupNameNotes} will fail if the (new) name is already used. Committing
- * the {@code GroupNameNotes} is necessary to make the adjustments for real.
+ * #forNewGroup(Project.NameKey, Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call
+ * {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it.
+ * For renaming, call {@link #forRename(Project.NameKey, Repository, AccountGroup.UUID,
+ * AccountGroup.NameKey, AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}.
+ * Both times, the creation of the {@code GroupNameNotes} will fail if the (new) name is already
+ * used. Committing the {@code GroupNameNotes} is necessary to make the adjustments for real.
  *
  * <p>The map has an additional benefit: We can quickly iterate over all group name/UUID pairs
  * without having to load all groups completely (which is costly).
@@ -87,6 +89,8 @@
  * </ul>
  */
 public class GroupNameNotes extends VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String SECTION_NAME = "group";
   private static final String UUID_PARAM = "uuid";
   private static final String NAME_PARAM = "name";
@@ -101,6 +105,7 @@
    * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
    * order to claim the new name and free up the old one.
    *
+   * @param projectName the name of the project which holds the commits of the notes
    * @param repository the repository which holds the commits of the notes
    * @param groupUuid the UUID of the group which is renamed
    * @param oldName the current name of the group
@@ -112,6 +117,7 @@
    * @throws OrmDuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forRename(
+      Project.NameKey projectName,
       Repository repository,
       AccountGroup.UUID groupUuid,
       AccountGroup.NameKey oldName,
@@ -121,7 +127,7 @@
     checkNotNull(newName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -133,6 +139,7 @@
    * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
    * order to claim the new name.
    *
+   * @param projectName the name of the project which holds the commits of the notes
    * @param repository the repository which holds the commits of the notes
    * @param groupUuid the UUID of the new group
    * @param groupName the name of the new group
@@ -142,12 +149,15 @@
    * @throws OrmDuplicateKeyException if a group with the new name already exists
    */
   public static GroupNameNotes forNewGroup(
-      Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      AccountGroup.NameKey groupName)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
     checkNotNull(groupName);
 
     GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
-    groupNameNotes.load(repository);
+    groupNameNotes.load(projectName, repository);
     groupNameNotes.ensureNewNameIsNotUsed();
     return groupNameNotes;
   }
@@ -323,6 +333,8 @@
   protected void onLoad() throws IOException, ConfigInvalidException {
     nameConflicting = false;
 
+    logger.atFine().log("Reading group notes");
+
     if (revision != null) {
       NoteMap noteMap = NoteMap.read(reader, revision);
       if (newGroupName.isPresent()) {
@@ -365,6 +377,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating group notes");
+
     NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
     if (oldGroupName.isPresent()) {
       removeNote(noteMap, oldGroupName.get(), inserter);
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 46fa998..f2289d4 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -70,14 +70,14 @@
   public Optional<InternalGroup> getGroup(AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      return getGroupFromNoteDb(allUsersRepo, groupUuid);
+      return getGroupFromNoteDb(allUsersName, allUsersRepo, groupUuid);
     }
   }
 
   private static Optional<InternalGroup> getGroupFromNoteDb(
-      Repository allUsersRepository, AccountGroup.UUID groupUuid)
+      AllUsersName allUsersName, Repository allUsersRepository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepository, groupUuid);
     Optional<InternalGroup> loadedGroup = groupConfig.getLoadedGroup();
     if (loadedGroup.isPresent()) {
       // Check consistency with group name notes.
@@ -110,16 +110,18 @@
    */
   public Stream<AccountGroup.UUID> getExternalGroups() throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      return getExternalGroupsFromNoteDb(allUsersRepo);
+      return getExternalGroupsFromNoteDb(allUsersName, allUsersRepo);
     }
   }
 
-  private static Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(Repository allUsersRepo)
+  private static Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(
+      AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
     ImmutableList<GroupReference> allInternalGroups = GroupNameNotes.loadAllGroups(allUsersRepo);
     ImmutableSet.Builder<AccountGroup.UUID> allSubgroups = ImmutableSet.builder();
     for (GroupReference internalGroup : allInternalGroups) {
-      Optional<InternalGroup> group = getGroupFromNoteDb(allUsersRepo, internalGroup.getUUID());
+      Optional<InternalGroup> group =
+          getGroupFromNoteDb(allUsersName, allUsersRepo, internalGroup.getUUID());
       group.map(InternalGroup::getSubgroups).ifPresent(allSubgroups::addAll);
     }
     return allSubgroups
@@ -131,15 +133,16 @@
   /**
    * Returns the membership audit records for a given group.
    *
-   * @param repo All-Users repository.
+   * @param allUsersRepo All-Users repository.
    * @param groupUuid the UUID of the group
    * @return the audit records, in arbitrary order; empty if the group does not exist
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupMemberAudit> getMembersAudit(Repository repo, AccountGroup.UUID groupUuid)
+  public List<AccountGroupMemberAudit> getMembersAudit(
+      Repository allUsersRepo, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
-    return auditLogReader.getMembersAudit(repo, groupUuid);
+    return auditLogReader.getMembersAudit(allUsersRepo, groupUuid);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index b5324f1..3182028 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -28,7 +28,9 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -53,6 +55,13 @@
 public class GroupsNoteDbConsistencyChecker {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final AllUsersName allUsersName;
+
+  @Inject
+  GroupsNoteDbConsistencyChecker(AllUsersName allUsersName) {
+    this.allUsersName = allUsersName;
+  }
+
   /**
    * The result of a consistency check. The UUID map is only non-null if no problems were detected.
    */
@@ -63,15 +72,15 @@
   }
 
   /** Checks for problems with the given All-Users repo. */
-  public Result check(Repository repo) throws IOException {
-    Result r = doCheck(repo);
+  public Result check(Repository allUsersRepo) throws IOException {
+    Result r = doCheck(allUsersRepo);
     if (!r.problems.isEmpty()) {
       r.uuidToGroupMap = null;
     }
     return r;
   }
 
-  private Result doCheck(Repository repo) throws IOException {
+  private Result doCheck(Repository allUsersRepo) throws IOException {
     Result result = new Result();
     result.problems = new ArrayList<>();
     result.uuidToGroupMap = new HashMap<>();
@@ -79,9 +88,9 @@
     BiMap<AccountGroup.UUID, String> uuidNameBiMap = HashBiMap.create();
 
     // Get all refs in an attempt to avoid seeing half committed group updates.
-    Map<String, Ref> refs = repo.getAllRefs();
-    readGroups(repo, refs, result);
-    readGroupNames(repo, refs, result, uuidNameBiMap);
+    Map<String, Ref> refs = allUsersRepo.getAllRefs();
+    readGroups(allUsersRepo, refs, result);
+    readGroupNames(allUsersRepo, refs, result, uuidNameBiMap);
     // The sequential IDs are not keys in NoteDb, so no need to check them.
 
     if (!result.problems.isEmpty()) {
@@ -94,7 +103,7 @@
     return result;
   }
 
-  private void readGroups(Repository repo, Map<String, Ref> refs, Result result)
+  private void readGroups(Repository allUsersRepo, Map<String, Ref> refs, Result result)
       throws IOException {
     for (Map.Entry<String, Ref> entry : refs.entrySet()) {
       if (!entry.getKey().startsWith(RefNames.REFS_GROUPS)) {
@@ -108,7 +117,8 @@
       }
       try {
         GroupConfig cfg =
-            GroupConfig.loadForGroupSnapshot(repo, uuid, entry.getValue().getObjectId());
+            GroupConfig.loadForGroupSnapshot(
+                allUsersName, allUsersRepo, uuid, entry.getValue().getObjectId());
         result.uuidToGroupMap.put(uuid, cfg.getLoadedGroup().get());
       } catch (ConfigInvalidException e) {
         result.problems.add(error("group %s does not parse: %s", uuid, e.getMessage()));
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index cd38c6a..314825b 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -232,9 +232,11 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
       GroupNameNotes groupNameNotes =
-          GroupNameNotes.forNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+          GroupNameNotes.forNewGroup(
+              allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
-      GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+      GroupConfig groupConfig =
+          GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
       groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
@@ -269,7 +271,7 @@
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
       groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
       if (!groupConfig.getLoadedGroup().isPresent()) {
         throw new NoSuchGroupException(groupUuid);
@@ -280,7 +282,8 @@
       if (groupUpdate.getName().isPresent()) {
         AccountGroup.NameKey oldName = originalGroup.getNameKey();
         AccountGroup.NameKey newName = groupUpdate.getName().get();
-        groupNameNotes = GroupNameNotes.forRename(allUsersRepo, groupUuid, oldName, newName);
+        groupNameNotes =
+            GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
       }
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index b7bb0dd..f8c17d1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -22,6 +23,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +33,8 @@
 import java.util.Optional;
 
 public class AccountIndexerImpl implements AccountIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     AccountIndexerImpl create(AccountIndexCollection indexes);
 
@@ -70,14 +75,29 @@
 
   @Override
   public void index(Account.Id id) throws IOException {
+    byIdCache.evict(id);
+    Optional<AccountState> accountState = byIdCache.get(id);
+
+    if (accountState.isPresent()) {
+      logger.atFine().log("Replace account %d in index", id.get());
+    } else {
+      logger.atFine().log("Delete account %d from index", id.get());
+    }
+
     for (Index<Account.Id, AccountState> i : getWriteIndexes()) {
       // Evict the cache to get an up-to-date value for sure.
-      byIdCache.evict(id);
-      Optional<AccountState> accountState = byIdCache.get(id);
       if (accountState.isPresent()) {
-        i.replace(accountState.get());
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.replace(accountState.get());
+        }
       } else {
-        i.delete(id);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleteing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.delete(id);
+        }
       }
     }
     fireAccountIndexedEvent(id.get());
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 0015268..acb7236 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -60,7 +59,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(AccountIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<Account.Id> ids;
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e947e60..d0a9749 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -33,6 +33,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -212,8 +214,14 @@
   }
 
   private void indexImpl(ChangeData cd) throws IOException {
+    logger.atFine().log("Replace change %d in index.", cd.getId().get());
     for (Index<?, ChangeData> i : getWriteIndexes()) {
-      i.replace(cd);
+      try (TraceTimer traceTimer =
+          TraceContext.newTimer(
+              "Replacing change %d in index version %d",
+              cd.getId().get(), i.getSchema().getVersion())) {
+        i.replace(cd);
+      }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
@@ -411,13 +419,17 @@
 
     @Override
     public Void call() throws IOException {
+      logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
       // Implementations should not need to access the DB in order to delete a
       // change ID.
       for (ChangeIndex i : getWriteIndexes()) {
-        i.delete(id);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleteing change %d in index version %d", id.get(), i.getSchema().getVersion())) {
+          i.delete(id);
+        }
       }
-      logger.atInfo().log("Deleted change %s from index.", id.get());
       fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 2823c2e..3474934 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -33,7 +33,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -64,7 +63,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(GroupIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<AccountGroup.UUID> uuids;
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index fcbdc57..d6ba253 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -22,6 +23,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -30,6 +33,8 @@
 import java.util.Optional;
 
 public class GroupIndexerImpl implements GroupIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     GroupIndexerImpl create(GroupIndexCollection indexes);
 
@@ -70,14 +75,29 @@
 
   @Override
   public void index(AccountGroup.UUID uuid) throws IOException {
+    // Evict the cache to get an up-to-date value for sure.
+    groupCache.evict(uuid);
+    Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+
+    if (internalGroup.isPresent()) {
+      logger.atFine().log("Replace group %s in index", uuid.get());
+    } else {
+      logger.atFine().log("Delete group %s from index", uuid.get());
+    }
+
     for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
-      // Evict the cache to get an up-to-date value for sure.
-      groupCache.evict(uuid);
-      Optional<InternalGroup> internalGroup = groupCache.get(uuid);
       if (internalGroup.isPresent()) {
-        i.replace(internalGroup.get());
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+          i.replace(internalGroup.get());
+        }
       } else {
-        i.delete(uuid);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting group %s in index version %d", uuid.get(), i.getSchema().getVersion())) {
+          i.delete(uuid);
+        }
       }
     }
     fireGroupIndexedEvent(uuid.get());
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
index a79bb7a..c2a28af 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.project;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -23,6 +24,8 @@
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +35,8 @@
 import java.util.Collections;
 
 public class ProjectIndexerImpl implements ProjectIndexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     ProjectIndexerImpl create(ProjectIndexCollection indexes);
 
@@ -69,14 +74,26 @@
   public void index(Project.NameKey nameKey) throws IOException {
     ProjectState projectState = projectCache.get(nameKey);
     if (projectState != null) {
+      logger.atFine().log("Replace project %s in index", nameKey.get());
       ProjectData projectData = projectState.toProjectData();
       for (ProjectIndex i : getWriteIndexes()) {
-        i.replace(projectData);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Replacing project %s in index version %d",
+                nameKey.get(), i.getSchema().getVersion())) {
+          i.replace(projectData);
+        }
       }
       fireProjectIndexedEvent(nameKey.get());
     } else {
+      logger.atFine().log("Delete project %s from index", nameKey.get());
       for (ProjectIndex i : getWriteIndexes()) {
-        i.delete(nameKey);
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Deleting project %s in index version %d",
+                nameKey.get(), i.getSchema().getVersion())) {
+          i.delete(nameKey);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
new file mode 100644
index 0000000..d3211f0
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "logging",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//lib:guava",
+        "//lib/flogger:api",
+    ],
+)
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
new file mode 100644
index 0000000..1e81c29
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.backend.Tags;
+import java.util.concurrent.Callable;
+import java.util.logging.Level;
+
+/**
+ * Logging context for Flogger.
+ *
+ * <p>To configure this logging context for Flogger set the following system property (also see
+ * {@link com.google.common.flogger.backend.system.DefaultPlatform}):
+ *
+ * <ul>
+ *   <li>{@code
+ *       flogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance}.
+ * </ul>
+ */
+public class LoggingContext extends com.google.common.flogger.backend.system.LoggingContext {
+  private static final LoggingContext INSTANCE = new LoggingContext();
+
+  private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
+
+  private LoggingContext() {}
+
+  /** This method is expected to be called via reflection (and might otherwise be unused). */
+  public static LoggingContext getInstance() {
+    return INSTANCE;
+  }
+
+  public static Runnable copy(Runnable runnable) {
+    if (runnable instanceof LoggingContextAwareRunnable) {
+      return runnable;
+    }
+    return new LoggingContextAwareRunnable(runnable);
+  }
+
+  public static <T> Callable<T> copy(Callable<T> callable) {
+    if (callable instanceof LoggingContextAwareCallable) {
+      return callable;
+    }
+    return new LoggingContextAwareCallable<>(callable);
+  }
+
+  @Override
+  public boolean shouldForceLogging(String loggerName, Level level, boolean isEnabled) {
+    return isLoggingForced();
+  }
+
+  @Override
+  public Tags getTags() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.getTags() : Tags.empty();
+  }
+
+  public ImmutableSetMultimap<String, String> getTagsAsMap() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.asMap() : ImmutableSetMultimap.of();
+  }
+
+  boolean addTag(String name, String value) {
+    return getMutableTags().add(name, value);
+  }
+
+  void removeTag(String name, String value) {
+    MutableTags mutableTags = getMutableTags();
+    mutableTags.remove(name, value);
+    if (mutableTags.isEmpty()) {
+      tags.remove();
+    }
+  }
+
+  void setTags(ImmutableSetMultimap<String, String> newTags) {
+    if (newTags.isEmpty()) {
+      tags.remove();
+      return;
+    }
+    getMutableTags().set(newTags);
+  }
+
+  void clearTags() {
+    tags.remove();
+  }
+
+  private MutableTags getMutableTags() {
+    MutableTags mutableTags = tags.get();
+    if (mutableTags == null) {
+      mutableTags = new MutableTags();
+      tags.set(mutableTags);
+    }
+    return mutableTags;
+  }
+
+  boolean isLoggingForced() {
+    Boolean force = forceLogging.get();
+    return force != null ? force : false;
+  }
+
+  boolean forceLogging(boolean force) {
+    Boolean oldValue = forceLogging.get();
+    if (force) {
+      forceLogging.set(true);
+    } else {
+      forceLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
new file mode 100644
index 0000000..6aff5c4
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.concurrent.Callable;
+
+/**
+ * Wrapper for a {@link Callable} that copies the {@link LoggingContext} from the current thread to
+ * the thread that executes the callable.
+ *
+ * <p>The state of the logging context that is copied to the thread that executes the callable is
+ * fixed at the creation time of this wrapper. If the callable is submitted to an executor and is
+ * executed later this means that changes that are done to the logging context in between creating
+ * and executing the callable do not apply.
+ *
+ * <p>See {@link LoggingContextAwareRunnable} for an example.
+ *
+ * @see LoggingContextAwareRunnable
+ */
+class LoggingContextAwareCallable<T> implements Callable<T> {
+  private final Callable<T> callable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+
+  LoggingContextAwareCallable(Callable<T> callable) {
+    this.callable = callable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+  }
+
+  @Override
+  public T call() throws Exception {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      return callable.call();
+    }
+
+    // propagate logging context
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
+    boolean oldForceLogging = loggingCtx.isLoggingForced();
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    try {
+      return callable.call();
+    } finally {
+      loggingCtx.setTags(oldTags);
+      loggingCtx.forceLogging(oldForceLogging);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java b/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java
new file mode 100644
index 0000000..17e152e
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareExecutorService.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * An {@link ExecutorService} that copies the {@link LoggingContext} on executing a {@link Runnable}
+ * to the executing thread.
+ */
+public class LoggingContextAwareExecutorService implements ExecutorService {
+  private final ExecutorService executorService;
+
+  public LoggingContextAwareExecutorService(ExecutorService executorService) {
+    this.executorService = executorService;
+  }
+
+  @Override
+  public void execute(Runnable command) {
+    executorService.execute(LoggingContext.copy(command));
+  }
+
+  @Override
+  public void shutdown() {
+    executorService.shutdown();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    return executorService.shutdownNow();
+  }
+
+  @Override
+  public boolean isShutdown() {
+    return executorService.isShutdown();
+  }
+
+  @Override
+  public boolean isTerminated() {
+    return executorService.isTerminated();
+  }
+
+  @Override
+  public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+    return executorService.awaitTermination(timeout, unit);
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    return executorService.submit(LoggingContext.copy(task));
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    return executorService.submit(LoggingContext.copy(task), result);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    return executorService.submit(LoggingContext.copy(task));
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException {
+    return executorService.invokeAll(tasks.stream().map(LoggingContext::copy).collect(toList()));
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(
+      Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException {
+    return executorService.invokeAll(
+        tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException, ExecutionException {
+    return executorService.invokeAny(tasks.stream().map(LoggingContext::copy).collect(toList()));
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    return executorService.invokeAny(
+        tasks.stream().map(LoggingContext::copy).collect(toList()), timeout, unit);
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
new file mode 100644
index 0000000..0bd7d00
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+
+/**
+ * Wrapper for a {@link Runnable} that copies the {@link LoggingContext} from the current thread to
+ * the thread that executes the runnable.
+ *
+ * <p>The state of the logging context that is copied to the thread that executes the runnable is
+ * fixed at the creation time of this wrapper. If the runnable is submitted to an executor and is
+ * executed later this means that changes that are done to the logging context in between creating
+ * and executing the runnable do not apply.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ *   try (TraceContext traceContext = TraceContext.newTrace(true, ...)) {
+ *     executor
+ *         .submit(new LoggingContextAwareRunnable(
+ *             () -> {
+ *               // Tracing is enabled since the runnable is created within the TraceContext.
+ *               // Tracing is even enabled if the executor runs the runnable only after the
+ *               // TraceContext was closed.
+ *
+ *               // The tag "foo=bar" is not set, since it was added to the logging context only
+ *               // after this runnable was created.
+ *
+ *               // do stuff
+ *             }))
+ *         .get();
+ *     traceContext.addTag("foo", "bar");
+ *   }
+ * </pre>
+ *
+ * @see LoggingContextAwareCallable
+ */
+public class LoggingContextAwareRunnable implements Runnable {
+  private final Runnable runnable;
+  private final Thread callingThread;
+  private final ImmutableSetMultimap<String, String> tags;
+  private final boolean forceLogging;
+
+  LoggingContextAwareRunnable(Runnable runnable) {
+    this.runnable = runnable;
+    this.callingThread = Thread.currentThread();
+    this.tags = LoggingContext.getInstance().getTagsAsMap();
+    this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+  }
+
+  public Runnable unwrap() {
+    return runnable;
+  }
+
+  @Override
+  public void run() {
+    if (callingThread.equals(Thread.currentThread())) {
+      // propagation of logging context is not needed
+      runnable.run();
+      return;
+    }
+
+    // propagate logging context
+    LoggingContext loggingCtx = LoggingContext.getInstance();
+    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
+    boolean oldForceLogging = loggingCtx.isLoggingForced();
+    loggingCtx.setTags(tags);
+    loggingCtx.forceLogging(forceLogging);
+    try {
+      runnable.run();
+    } finally {
+      loggingCtx.setTags(oldTags);
+      loggingCtx.forceLogging(oldForceLogging);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java b/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java
new file mode 100644
index 0000000..e17a91e
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareScheduledExecutorService.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A {@link ScheduledExecutorService} that copies the {@link LoggingContext} on executing a {@link
+ * Runnable} to the executing thread.
+ */
+public class LoggingContextAwareScheduledExecutorService extends LoggingContextAwareExecutorService
+    implements ScheduledExecutorService {
+  private final ScheduledExecutorService scheduledExecutorService;
+
+  public LoggingContextAwareScheduledExecutorService(
+      ScheduledExecutorService scheduledExecutorService) {
+    super(scheduledExecutorService);
+    this.scheduledExecutorService = scheduledExecutorService;
+  }
+
+  @Override
+  public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+    return scheduledExecutorService.schedule(LoggingContext.copy(command), delay, unit);
+  }
+
+  @Override
+  public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+    return scheduledExecutorService.schedule(LoggingContext.copy(callable), delay, unit);
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleAtFixedRate(
+      Runnable command, long initialDelay, long period, TimeUnit unit) {
+    return scheduledExecutorService.scheduleAtFixedRate(
+        LoggingContext.copy(command), initialDelay, period, unit);
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleWithFixedDelay(
+      Runnable command, long initialDelay, long delay, TimeUnit unit) {
+    return scheduledExecutorService.scheduleWithFixedDelay(
+        LoggingContext.copy(command), initialDelay, delay, unit);
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
new file mode 100644
index 0000000..a936a43
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.backend.Tags;
+
+public class MutableTags {
+  private final SetMultimap<String, String> tagMap =
+      MultimapBuilder.hashKeys().hashSetValues().build();
+  private Tags tags = Tags.empty();
+
+  public Tags getTags() {
+    return tags;
+  }
+
+  /**
+   * Adds a tag if a tag with the same name and value doesn't exist yet.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   * @return {@code true} if the tag was added, {@code false} if the tag was not added because it
+   *     already exists
+   */
+  public boolean add(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    boolean ret = tagMap.put(name, value);
+    if (ret) {
+      buildTags();
+    }
+    return ret;
+  }
+
+  /**
+   * Removes the tag with the given name and value.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   */
+  public void remove(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    if (tagMap.remove(name, value)) {
+      buildTags();
+    }
+  }
+
+  /**
+   * Checks if the contained tag map is empty.
+   *
+   * @return {@code true} if there are no tags, otherwise {@code false}
+   */
+  public boolean isEmpty() {
+    return tagMap.isEmpty();
+  }
+
+  /** Clears all tags. */
+  public void clear() {
+    tagMap.clear();
+    tags = Tags.empty();
+  }
+
+  /**
+   * Returns the tags as Multimap.
+   *
+   * @return the tags as Multimap
+   */
+  public ImmutableSetMultimap<String, String> asMap() {
+    return ImmutableSetMultimap.copyOf(tagMap);
+  }
+
+  /**
+   * Replaces the existing tags with the provided tags.
+   *
+   * @param tags the tags that should be set.
+   */
+  void set(ImmutableSetMultimap<String, String> tags) {
+    tagMap.clear();
+    tags.forEach(tagMap::put);
+    buildTags();
+  }
+
+  private void buildTags() {
+    if (tagMap.isEmpty()) {
+      if (tags.isEmpty()) {
+        return;
+      }
+      tags = Tags.empty();
+      return;
+    }
+
+    Tags.Builder tagsBuilder = Tags.builder();
+    tagMap.forEach(tagsBuilder::addTag);
+    tags = tagsBuilder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
similarity index 70%
rename from java/com/google/gerrit/server/util/RequestId.java
rename to java/com/google/gerrit/server/logging/RequestId.java
index 8e8db12..b0a8ad9 100644
--- a/java/com/google/gerrit/server/util/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.server.logging;
 
+import com.google.common.base.Enums;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 
@@ -36,27 +36,34 @@
     MACHINE_ID = id;
   }
 
-  public static RequestId forChange(Change c) {
-    return new RequestId(c.getId().toString());
+  public enum Type {
+    RECEIVE_ID,
+    SUBMISSION_ID,
+    TRACE_ID;
+
+    static boolean isId(String id) {
+      return id != null && Enums.getIfPresent(Type.class, id).isPresent();
+    }
   }
 
-  public static RequestId forProject(Project.NameKey p) {
-    return new RequestId(p.toString());
+  public static boolean isSet() {
+    return LoggingContext.getInstance().getTagsAsMap().keySet().stream().anyMatch(Type::isId);
   }
 
   private final String str;
 
-  private RequestId(String resourceId) {
+  public RequestId() {
+    this(null);
+  }
+
+  public RequestId(@Nullable String resourceId) {
     Hasher h = Hashing.murmur3_128().newHasher();
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
-        "["
-            + resourceId
-            + "-"
+        (resourceId != null ? resourceId + "-" : "")
             + TimeUtil.nowTs().getTime()
             + "-"
-            + h.hash().toString().substring(0, 8)
-            + "]";
+            + h.hash().toString().substring(0, 8);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
new file mode 100644
index 0000000..977baa5
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -0,0 +1,263 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Strings;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * TraceContext that allows to set logging tags and enforce logging.
+ *
+ * <p>The logging tags are attached to all log entries that are triggered while the trace context is
+ * open. If force logging is enabled all logs that are triggered while the trace context is open are
+ * written to the log file regardless of the configured log level.
+ *
+ * <pre>
+ * try (TraceContext traceContext = TraceContext.open()
+ *         .addTag("tag-name", "tag-value")
+ *         .forceLogging()) {
+ *     // This gets logged as: A log [CONTEXT forced=true tag-name="tag-value" ]
+ *     // Since force logging is enabled this gets logged independently of the configured log
+ *     // level.
+ *     logger.atFinest().log("A log");
+ *
+ *     // do stuff
+ * }
+ * </pre>
+ *
+ * <p>The logging tags and the force logging flag are stored in the {@link LoggingContext}. {@link
+ * LoggingContextAwareExecutorService}, {@link LoggingContextAwareScheduledExecutorService} and the
+ * executor in {@link com.google.gerrit.server.git.WorkQueue} ensure that the logging context is
+ * automatically copied to background threads.
+ *
+ * <p>On close of the trace context newly set tags are unset. Force logging is disabled on close if
+ * it got enabled while the trace context was open.
+ *
+ * <p>Trace contexts can be nested:
+ *
+ * <pre>
+ * // Initially there are no tags
+ * logger.atSevere().log("log without tag");
+ *
+ * // a tag can be set by opening a trace context
+ * try (TraceContext ctx = TraceContext.open().addTag("tag1", "value1")) {
+ *   logger.atSevere().log("log with tag1=value1");
+ *
+ *   // while a trace context is open further tags can be added.
+ *   ctx.addTag("tag2", "value2")
+ *   logger.atSevere().log("log with tag1=value1 and tag2=value2");
+ *
+ *   // also by opening another trace context a another tag can be added
+ *   try (TraceContext ctx2 = TraceContext.open().addTag("tag3", "value3")) {
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2 and tag3=value3");
+ *
+ *     // it's possible to have the same tag name with multiple values
+ *     ctx2.addTag("tag3", "value3a")
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *
+ *     // adding a tag with the same name and value as an existing tag has no effect
+ *     try (TraceContext ctx3 = TraceContext.open().addTag("tag3", "value3a")) {
+ *       logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *     }
+ *
+ *     // closing ctx3 didn't remove tag3=value3a since it was already set before opening ctx3
+ *     logger.atSevere().log("log with tag1=value1, tag2=value2, tag3=value3 and tag3=value3a");
+ *   }
+ *
+ *   // closing ctx2 removed tag3=value3 and tag3-value3a
+ *   logger.atSevere().log("with tag1=value1 and tag2=value2");
+ * }
+ *
+ * // closing ctx1 removed tag1=value1 and tag2=value2
+ * logger.atSevere().log("log without tag");
+ * </pre>
+ */
+public class TraceContext implements AutoCloseable {
+  public static TraceContext open() {
+    return new TraceContext();
+  }
+
+  /**
+   * Opens a new trace context for request tracing.
+   *
+   * <ul>
+   *   <li>sets a tag with a trace ID
+   *   <li>enables force logging
+   * </ul>
+   *
+   * <p>if no trace ID is provided a new trace ID is only generated if request tracing was not
+   * started yet. If request tracing was already started the given {@code traceIdConsumer} is
+   * invoked with the existing trace ID and no new logging tag is set.
+   *
+   * <p>No-op if {@code trace} is {@code false}.
+   *
+   * @param trace whether tracing should be started
+   * @param traceId trace ID that should be used for tracing, if {@code null} a trace ID is
+   *     generated
+   * @param traceIdConsumer consumer for the trace ID, should be used to return the generated trace
+   *     ID to the client, not invoked if {@code trace} is {@code false}
+   * @return the trace context
+   */
+  public static TraceContext newTrace(
+      boolean trace, @Nullable String traceId, TraceIdConsumer traceIdConsumer) {
+    if (!trace) {
+      // Create an empty trace context.
+      return open();
+    }
+
+    if (!Strings.isNullOrEmpty(traceId)) {
+      traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), traceId);
+      return open().addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
+    }
+
+    Optional<String> existingTraceId =
+        LoggingContext.getInstance()
+            .getTagsAsMap()
+            .get(RequestId.Type.TRACE_ID.name())
+            .stream()
+            .findAny();
+    if (existingTraceId.isPresent()) {
+      // request tracing was already started, no need to generate a new trace ID
+      traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), existingTraceId.get());
+      return open();
+    }
+
+    RequestId newTraceId = new RequestId();
+    traceIdConsumer.accept(RequestId.Type.TRACE_ID.name(), newTraceId.toString());
+    return open().addTag(RequestId.Type.TRACE_ID, newTraceId).forceLogging();
+  }
+
+  @FunctionalInterface
+  public interface TraceIdConsumer {
+    void accept(String tagName, String traceId);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param message the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String message) {
+    return new TraceTimer(message);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param format the message format string
+   * @param arg argument for the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String format, Object arg) {
+    return new TraceTimer(format, arg);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * <p>If request tracing is not enabled this is a no-op.
+   *
+   * @param format the message format string
+   * @param arg1 first argument for the message
+   * @param arg2 second argument for the message
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(String format, Object arg1, Object arg2) {
+    return new TraceTimer(format, arg1, arg2);
+  }
+
+  public static class TraceTimer implements AutoCloseable {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+    private final Consumer<Long> logFn;
+    private final Stopwatch stopwatch;
+
+    private TraceTimer(String message) {
+      this(elapsedMs -> logger.atFine().log(message + " (%d ms)", elapsedMs));
+    }
+
+    private TraceTimer(String format, @Nullable Object arg) {
+      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg, elapsedMs));
+    }
+
+    private TraceTimer(String format, @Nullable Object arg1, @Nullable Object arg2) {
+      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, elapsedMs));
+    }
+
+    private TraceTimer(Consumer<Long> logFn) {
+      this.logFn = logFn;
+      this.stopwatch = Stopwatch.createStarted();
+    }
+
+    @Override
+    public void close() {
+      stopwatch.stop();
+      logFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+    }
+  }
+
+  // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
+  private final Table<String, String, Boolean> tags = HashBasedTable.create();
+
+  private boolean stopForceLoggingOnClose;
+
+  private TraceContext() {}
+
+  public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
+    return addTag(checkNotNull(requestId, "request ID is required").name(), tagValue);
+  }
+
+  public TraceContext addTag(String tagName, Object tagValue) {
+    String name = checkNotNull(tagName, "tag name is required");
+    String value = checkNotNull(tagValue, "tag value is required").toString();
+    tags.put(name, value, LoggingContext.getInstance().addTag(name, value));
+    return this;
+  }
+
+  public TraceContext forceLogging() {
+    if (stopForceLoggingOnClose) {
+      return this;
+    }
+
+    stopForceLoggingOnClose = !LoggingContext.getInstance().forceLogging(true);
+    return this;
+  }
+
+  @Override
+  public void close() {
+    for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
+      if (cell.getValue()) {
+        LoggingContext.getInstance().removeTag(cell.getRowKey(), cell.getColumnKey());
+      }
+    }
+    if (stopForceLoggingOnClose) {
+      LoggingContext.getInstance().forceLogging(false);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index eee8c60..1549f8d 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -53,7 +53,8 @@
     }
 
     boolean match = mailPattern.matcher(message.from().getEmail()).find();
-    if (mode == ListFilterMode.WHITELIST && !match || mode == ListFilterMode.BLACKLIST && match) {
+    if ((mode == ListFilterMode.WHITELIST && !match)
+        || (mode == ListFilterMode.BLACKLIST && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
       return false;
     }
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 54176e2..0baaa11c 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -239,7 +239,7 @@
       }
     }
 
-    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    groups.sort(Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
     return groups;
   }
 
diff --git a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index eecf935..0e9a2b7 100644
--- a/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mime;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -22,12 +24,9 @@
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.InputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
@@ -115,16 +114,7 @@
       return MimeUtil2.UNKNOWN_MIME_TYPE;
     }
 
-    final List<MimeType> types = new ArrayList<>(mimeTypes);
-    Collections.sort(
-        types,
-        new Comparator<MimeType>() {
-          @Override
-          public int compare(MimeType a, MimeType b) {
-            return getCorrectedMimeSpecificity(b) - getCorrectedMimeSpecificity(a);
-          }
-        });
-    return types.get(0);
+    return Collections.max(mimeTypes, comparing(this::getCorrectedMimeSpecificity));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index a083a71..632f6fc 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -145,6 +145,7 @@
     if (loaded) {
       return self();
     }
+
     boolean read = args.migration.readChanges();
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 010c5c0..e0cc771 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -41,6 +42,8 @@
 
 /** A single delta related to a specific patch-set of a change. */
 public abstract class AbstractChangeUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final NotesMigration migration;
   protected final ChangeNoteUtil noteUtil;
   protected final Account.Id accountId;
@@ -218,6 +221,11 @@
 
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     checkNotReadOnly();
+
+    logger.atFinest().log(
+        "%s for change %s of project %s in %s (NoteDb)",
+        getClass().getSimpleName(), getId(), getProjectName(), getRefName());
+
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
     if (cb == null) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
index 1d3c752..0ebee1a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -17,11 +17,16 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
 import static com.google.gerrit.common.TimeUtil.truncateToSecond;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsFirst;
 import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
@@ -31,7 +36,6 @@
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -43,13 +47,14 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -70,7 +75,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TreeMap;
 
 /**
  * A bundle of all entities rooted at a single {@link Change} entity.
@@ -105,110 +109,65 @@
         Source.NOTE_DB);
   }
 
-  private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
-      Iterable<ChangeMessage> in) {
-    Map<ChangeMessage.Key, ChangeMessage> out =
-        new TreeMap<>(
-            new Comparator<ChangeMessage.Key>() {
-              @Override
-              public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
-                return ComparisonChain.start()
-                    .compare(a.getParentKey().get(), b.getParentKey().get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (ChangeMessage cm : in) {
-      out.put(cm.getKey(), cm);
-    }
-    return out;
+  private static ImmutableSortedMap<ChangeMessage.Key, ChangeMessage> changeMessageMap(
+      Collection<ChangeMessage> in) {
+    return in.stream()
+        .collect(
+            toImmutableSortedMap(
+                comparing((ChangeMessage.Key k) -> k.getParentKey().get())
+                    .thenComparing(k -> k.get()),
+                cm -> cm.getKey(),
+                cm -> cm));
   }
 
   // Unlike the *Map comparators, which are intended to make key lists diffable,
   // this comparator sorts first on timestamp, then on every other field.
-  private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
-      new Ordering<ChangeMessage>() {
-        final Ordering<Comparable<?>> nullsFirst = Ordering.natural().nullsFirst();
-
-        @Override
-        public int compare(ChangeMessage a, ChangeMessage b) {
-          return ComparisonChain.start()
-              .compare(a.getWrittenOn(), b.getWrittenOn())
-              .compare(a.getKey().getParentKey().get(), b.getKey().getParentKey().get())
-              .compare(psId(a), psId(b), nullsFirst)
-              .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
-              .compare(a.getMessage(), b.getMessage(), nullsFirst)
-              .result();
-        }
-
-        private Integer psId(ChangeMessage m) {
-          return m.getPatchSetId() != null ? m.getPatchSetId().get() : null;
-        }
-      };
+  private static final Comparator<ChangeMessage> CHANGE_MESSAGE_COMPARATOR =
+      comparing(ChangeMessage::getWrittenOn)
+          .thenComparing(m -> m.getKey().getParentKey().get())
+          .thenComparing(
+              m -> m.getPatchSetId() != null ? m.getPatchSetId().get() : null,
+              nullsFirst(naturalOrder()))
+          .thenComparing(ChangeMessage::getAuthor, intKeyOrdering())
+          .thenComparing(ChangeMessage::getMessage, nullsFirst(naturalOrder()));
 
   private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
-    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
+    return Streams.stream(in).sorted(CHANGE_MESSAGE_COMPARATOR).collect(toImmutableList());
   }
 
-  private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
-    TreeMap<PatchSet.Id, PatchSet> out =
-        new TreeMap<>(
-            new Comparator<PatchSet.Id>() {
-              @Override
-              public int compare(PatchSet.Id a, PatchSet.Id b) {
-                return patchSetIdChain(a, b).result();
-              }
-            });
-    for (PatchSet ps : in) {
-      out.put(ps.getId(), ps);
-    }
-    return out;
+  private static ImmutableSortedMap<Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
+    return Streams.stream(in)
+        .collect(toImmutableSortedMap(patchSetIdComparator(), PatchSet::getId, ps -> ps));
   }
 
-  private static Map<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
+  private static ImmutableSortedMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
       Iterable<PatchSetApproval> in) {
-    Map<PatchSetApproval.Key, PatchSetApproval> out =
-        new TreeMap<>(
-            new Comparator<PatchSetApproval.Key>() {
-              @Override
-              public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
-                return patchSetIdChain(a.getParentKey(), b.getParentKey())
-                    .compare(a.getAccountId().get(), b.getAccountId().get())
-                    .compare(a.getLabelId(), b.getLabelId())
-                    .result();
-              }
-            });
-    for (PatchSetApproval psa : in) {
-      out.put(psa.getKey(), psa);
-    }
-    return out;
+    return Streams.stream(in)
+        .collect(
+            toImmutableSortedMap(
+                comparing(PatchSetApproval.Key::getParentKey, patchSetIdComparator())
+                    .thenComparing(PatchSetApproval.Key::getAccountId, intKeyOrdering())
+                    .thenComparing(PatchSetApproval.Key::getLabelId),
+                PatchSetApproval::getKey,
+                a -> a));
   }
 
-  private static Map<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
+  private static ImmutableSortedMap<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
       Iterable<PatchLineComment> in) {
-    Map<PatchLineComment.Key, PatchLineComment> out =
-        new TreeMap<>(
-            new Comparator<PatchLineComment.Key>() {
-              @Override
-              public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
-                Patch.Key pka = a.getParentKey();
-                Patch.Key pkb = b.getParentKey();
-                return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
-                    .compare(pka.get(), pkb.get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (PatchLineComment plc : in) {
-      out.put(plc.getKey(), plc);
-    }
-    return out;
+    return Streams.stream(in)
+        .collect(
+            toImmutableSortedMap(
+                comparing(
+                        (PatchLineComment.Key k) -> k.getParentKey().getParentKey(),
+                        patchSetIdComparator())
+                    .thenComparing(PatchLineComment.Key::getParentKey)
+                    .thenComparing(PatchLineComment.Key::get),
+                PatchLineComment::getKey,
+                c -> c));
   }
 
-  private static ComparisonChain patchSetIdChain(PatchSet.Id a, PatchSet.Id b) {
-    return ComparisonChain.start()
-        .compare(a.getParentKey().get(), b.getParentKey().get())
-        .compare(a.get(), b.get());
+  private static Comparator<PatchSet.Id> patchSetIdComparator() {
+    return comparing((PatchSet.Id id) -> id.getParentKey().get()).thenComparing(id -> id.get());
   }
 
   static {
@@ -436,7 +395,7 @@
       excludeOrigSubj = true;
       String aTopic = trimOrNull(a.getTopic());
       excludeTopic =
-          Objects.equals(aTopic, b.getTopic()) || "".equals(aTopic) && b.getTopic() == null;
+          Objects.equals(aTopic, b.getTopic()) || ("".equals(aTopic) && b.getTopic() == null);
       aUpdated = bundleA.getLatestTimestamp();
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
       boolean createdOnMatchesFirstPs =
@@ -454,7 +413,7 @@
       excludeOrigSubj = true;
       String bTopic = trimOrNull(b.getTopic());
       excludeTopic =
-          Objects.equals(bTopic, a.getTopic()) || a.getTopic() == null && "".equals(bTopic);
+          Objects.equals(bTopic, a.getTopic()) || (a.getTopic() == null && "".equals(bTopic));
       bUpdated = bundleB.getLatestTimestamp();
     }
 
@@ -598,9 +557,10 @@
     }
     if (!bs.isEmpty()) {
       sb.append("Only in B:");
-      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
-        sb.append("\n  ").append(cm);
-      }
+      bs.values()
+          .stream()
+          .sorted(CHANGE_MESSAGE_COMPARATOR)
+          .forEach(cm -> sb.append("\n  ").append(cm));
     }
     diffs.add(sb.toString());
   }
@@ -758,7 +718,8 @@
         excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
       } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
         excludeGranted =
-            tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()) || tb.compareTo(ta) < 0;
+            (tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()))
+                || (tb.compareTo(ta) < 0);
         excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
       }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index f0187ed..cc316e5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -18,6 +18,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -25,10 +26,10 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.inject.Inject;
@@ -46,6 +47,8 @@
 
 @Singleton
 public class ChangeNotesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @VisibleForTesting static final String CACHE_NAME = "change_notes";
 
   public static Module module() {
@@ -345,6 +348,8 @@
 
     @Override
     public ChangeNotesState call() throws ConfigInvalidException, IOException {
+      logger.atFine().log(
+          "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
               key.changeId(),
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 4eeab81..cbb7020 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -79,7 +79,6 @@
 import java.sql.Timestamp;
 import java.text.ParseException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -297,9 +296,7 @@
       }
       result.put(a.getPatchSetId(), a);
     }
-    for (Collection<PatchSetApproval> v : result.asMap().values()) {
-      Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
-    }
+    result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 11d6880..c51aec3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -23,7 +23,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -53,14 +53,14 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gson.Gson;
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index bd7f8fc..b60a332 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -63,9 +63,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 79da7e1..81d32d9 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -150,6 +150,8 @@
       return;
     }
 
+    logger.atFine().log(
+        "Load draft comment notes for change %s of project %s", getChangeId(), getProjectName());
     RevCommit tipCommit = handle.walk().parseCommit(rev);
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
index 7931d88..c9711b5 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -30,8 +32,6 @@
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -87,8 +87,7 @@
       return;
     }
 
-    List<Integer> psIds = new ArrayList<>(comments.keySet());
-    Collections.sort(psIds);
+    ImmutableList<Integer> psIds = comments.keySet().stream().sorted().collect(toImmutableList());
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index 7eb3a54..a05e6a1 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -34,6 +35,8 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class RobotCommentNotes extends AbstractChangeNotes<RobotCommentNotes> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     RobotCommentNotes create(Change change);
   }
@@ -86,6 +89,8 @@
     }
     metaId = metaId.copy();
 
+    logger.atFine().log(
+        "Load robot comment notes for change %s of project %s", getChangeId(), getProjectName());
     RevCommit tipCommit = handle.walk().parseCommit(metaId);
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index 5359479..eb6a280 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -31,7 +32,8 @@
   @Singleton
   @DiffExecutor
   public ExecutorService createDiffExecutor() {
-    return Executors.newCachedThreadPool(
-        new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build());
+    return new LoggingContextAwareExecutorService(
+        Executors.newCachedThreadPool(
+            new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index 8bca19f..9153638 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -19,7 +19,6 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Callable;
 
@@ -66,8 +65,9 @@
           break;
       }
     }
-    Collections.sort(r);
     return new DiffSummary(
-        r.toArray(new String[r.size()]), patchList.getInsertions(), patchList.getDeletions());
+        r.stream().sorted().toArray(String[]::new),
+        patchList.getInsertions(),
+        patchList.getDeletions());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index cf5df4a..dd717ba 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -47,12 +47,7 @@
   private static final long serialVersionUID = PatchListKey.serialVersionUID;
 
   private static final Comparator<PatchListEntry> PATCH_CMP =
-      new Comparator<PatchListEntry>() {
-        @Override
-        public int compare(PatchListEntry a, PatchListEntry b) {
-          return comparePaths(a.getNewName(), b.getNewName());
-        }
-      };
+      Comparator.comparing(PatchListEntry::getNewName, PatchList::comparePaths);
 
   @VisibleForTesting
   static int comparePaths(String a, String b) {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index b4f7251..61f0180 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.CommentDetail;
@@ -34,7 +35,6 @@
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
@@ -55,13 +55,7 @@
   static final int MAX_CONTEXT = 5000000;
   static final int BIG_FILE = 9000;
 
-  private static final Comparator<Edit> EDIT_SORT =
-      new Comparator<Edit>() {
-        @Override
-        public int compare(Edit o1, Edit o2) {
-          return o1.getBeginA() - o2.getBeginA();
-        }
-      };
+  private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
 
   private Repository db;
   private Project.NameKey projectKey;
@@ -285,6 +279,13 @@
     int aSize = a.src.size();
     int bSize = b.src.size();
 
+    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
+      // The diff was requested for a file which was either added or deleted but which JGit doesn't
+      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
+      // renamed file looks like a deletion).
+      return;
+    }
+
     Optional<Edit> lastEdit = getLast(edits);
     if (isNewlineAtEndDeleted()) {
       Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
@@ -362,7 +363,7 @@
     // them correctly later.
     //
     edits.addAll(empty);
-    Collections.sort(edits, EDIT_SORT);
+    edits.sort(EDIT_SORT);
   }
 
   private void safeAdd(List<Edit> empty, Edit toAdd) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 136b4ae..51a0f95 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -19,6 +19,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
@@ -46,6 +47,8 @@
 
 @Singleton
 public class DefaultPermissionBackend extends PermissionBackend {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
 
   private final Provider<CurrentUser> currentUser;
@@ -186,6 +189,13 @@
     private boolean isAdmin() {
       if (admin == null) {
         admin = computeAdmin();
+        if (admin) {
+          logger.atFinest().log(
+              "user %s is an administrator of the server", user.getLoggableName());
+        } else {
+          logger.atFinest().log(
+              "user %s is not an administrator of the server", user.getLoggableName());
+        }
       }
       return admin;
     }
@@ -210,11 +220,32 @@
 
     private boolean canEmailReviewers() {
       List<PermissionRule> email = capabilities().emailReviewers;
-      return allow(email) || notDenied(email);
+      if (allow(email)) {
+        logger.atFinest().log(
+            "user %s can email reviewers (allowed by %s)", user.getLoggableName(), email);
+        return true;
+      }
+
+      if (notDenied(email)) {
+        logger.atFinest().log(
+            "user %s can email reviewers (not denied by %s)", user.getLoggableName(), email);
+        return true;
+      }
+
+      logger.atFinest().log("user %s cannot email reviewers", user.getLoggableName());
+      return false;
     }
 
     private boolean has(String permissionName) {
-      return allow(capabilities().getPermission(checkNotNull(permissionName)));
+      boolean has = allow(capabilities().getPermission(checkNotNull(permissionName)));
+      if (has) {
+        logger.atFinest().log(
+            "user %s has global capability %s", user.getLoggableName(), permissionName);
+      } else {
+        logger.atFinest().log(
+            "user %s doesn't have global capability %s", user.getLoggableName(), permissionName);
+      }
+      return has;
     }
 
     private boolean allow(Collection<PermissionRule> rules) {
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 71718fb..01ef725 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.access.GerritPermission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.registration.PluginName;
 import java.lang.annotation.Annotation;
 import java.util.Collections;
 import java.util.LinkedHashSet;
@@ -116,7 +117,7 @@
       Class<?> annotationClass)
       throws PermissionBackendException {
     if (pluginName != null
-        && !"gerrit".equals(pluginName)
+        && !PluginName.GERRIT.equals(pluginName)
         && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
       return new PluginPermission(pluginName, capability, fallBackToAdmin);
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
new file mode 100644
index 0000000..6018263
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.access.GerritPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import java.util.Optional;
+
+/**
+ * This signals that some permission check failed. The message is short so it can print on a
+ * single-line in the Git output.
+ */
+public class PermissionDeniedException extends AuthException {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE_PREFIX = "not permitted: ";
+
+  private final GerritPermission permission;
+  private final Optional<String> resource;
+
+  public PermissionDeniedException(GerritPermission permission) {
+    super(MESSAGE_PREFIX + checkNotNull(permission).describeForException());
+    this.permission = permission;
+    this.resource = Optional.empty();
+  }
+
+  public PermissionDeniedException(GerritPermission permission, String resource) {
+    super(
+        MESSAGE_PREFIX
+            + checkNotNull(permission).describeForException()
+            + " on "
+            + checkNotNull(resource));
+    this.permission = permission;
+    this.resource = Optional.of(resource);
+  }
+
+  public String describePermission() {
+    return permission.describeForException();
+  }
+
+  public Optional<String> getResource() {
+    return resource;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 67662c7..4a4ea37 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
@@ -212,6 +213,10 @@
     return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
   }
 
+  private boolean canAddTagRefs() {
+    return (canPerformOnTagRef(Permission.CREATE) || isAdmin());
+  }
+
   private boolean canCreateChanges() {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
@@ -233,6 +238,26 @@
     return declaredOwner;
   }
 
+  private boolean canPerformOnTagRef(String permissionName) {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.getSection();
+
+      if (section.getName().startsWith(REFS_TAGS)) {
+        Permission permission = section.getPermission(permissionName);
+        if (permission == null) {
+          continue;
+        }
+
+        Boolean can = canPerform(permissionName, section, permission);
+        if (can != null) {
+          return can;
+        }
+      }
+    }
+
+    return false;
+  }
+
   private boolean canPerformOnAnyRef(String permissionName) {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
@@ -241,25 +266,33 @@
         continue;
       }
 
-      for (PermissionRule rule : permission.getRules()) {
-        if (rule.isBlock() || rule.isDeny() || !match(rule)) {
-          continue;
-        }
-
-        // Being in a group that was granted this permission is only an
-        // approximation.  There might be overrides and doNotInherit
-        // that would render this to be false.
-        //
-        if (controlForRef(section.getName()).canPerform(permissionName)) {
-          return true;
-        }
-        break;
+      Boolean can = canPerform(permissionName, section, permission);
+      if (can != null) {
+        return can;
       }
     }
 
     return false;
   }
 
+  private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
+    for (PermissionRule rule : permission.getRules()) {
+      if (rule.isBlock() || rule.isDeny() || !match(rule)) {
+        continue;
+      }
+
+      // Being in a group that was granted this permission is only an
+      // approximation.  There might be overrides and doNotInherit
+      // that would render this to be false.
+      //
+      if (controlForRef(section.getName()).canPerform(permissionName)) {
+        return true;
+      }
+      break;
+    }
+    return null;
+  }
+
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
     boolean canPerform = false;
     Set<String> patterns = allRefPatterns(permission);
@@ -403,6 +436,8 @@
 
         case CREATE_REF:
           return canAddRefs();
+        case CREATE_TAG_REF:
+          return canAddTagRefs();
         case CREATE_CHANGE:
           return canCreateChanges();
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index 3fee6cf..7c58ccb 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -51,6 +51,21 @@
   CREATE_REF,
 
   /**
+   * Can create at least one tag reference in the project.
+   *
+   * <p>This project level permission only validates the user may create some tag reference within
+   * the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE);
+   * </pre>
+   */
+  CREATE_TAG_REF,
+
+  /**
    * Can create at least one change in the project.
    *
    * <p>This project level permission only validates the user may create a change for some branch
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 3bd2817..762dfbe 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
@@ -41,6 +42,8 @@
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectControl projectControl;
   private final String refName;
@@ -392,15 +395,37 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
+      logger.atFine().log(
+          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+              + " because this permission is blocked",
+          getUser().getLoggableName(),
+          permissionName,
+          withForce,
+          projectControl.getProject().getName(),
+          refName);
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+        logger.atFine().log(
+            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+            getUser().getLoggableName(),
+            permissionName,
+            withForce,
+            projectControl.getProject().getName(),
+            refName);
         return true;
       }
     }
 
+    logger.atFine().log(
+        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+        getUser().getLoggableName(),
+        permissionName,
+        withForce,
+        projectControl.getProject().getName(),
+        refName);
     return false;
   }
 
@@ -449,7 +474,88 @@
     @Override
     public void check(RefPermission perm) throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted for " + refName);
+        PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
+        switch (perm) {
+          case UPDATE:
+            if (refName.equals(RefNames.REFS_CONFIG)) {
+              pde.setAdvice(
+                  "Configuration changes can only be pushed by project owners\n"
+                      + "who also have 'Push' rights on "
+                      + RefNames.REFS_CONFIG);
+            } else {
+              pde.setAdvice("To push into this reference you need 'Push' rights.");
+            }
+            break;
+          case DELETE:
+            pde.setAdvice(
+                "You need 'Delete Reference' rights or 'Push' rights with the \n"
+                    + "'Force Push' flag set to delete references.");
+            break;
+          case CREATE_CHANGE:
+            // This is misleading in the default permission backend, since "create change" on a
+            // branch is encoded as "push" on refs/for/DESTINATION.
+            pde.setAdvice(
+                "You need 'Create Change' rights to upload code review requests.\n"
+                    + "Verify that you are pushing to the right branch.");
+            break;
+          case CREATE:
+            pde.setAdvice("You need 'Create' rights to create new references.");
+            break;
+          case CREATE_SIGNED_TAG:
+            pde.setAdvice("You need 'Create Signed Tag' rights to push a signed tag.");
+            break;
+          case CREATE_TAG:
+            pde.setAdvice("You need 'Create Tag' rights to push a normal tag.");
+            break;
+          case FORCE_UPDATE:
+            pde.setAdvice(
+                "You need 'Push' rights with 'Force' flag set to do a non-fastforward push.");
+            break;
+          case FORGE_AUTHOR:
+            pde.setAdvice(
+                "You need 'Forge Author' rights to push commits with another user as author.");
+            break;
+          case FORGE_COMMITTER:
+            pde.setAdvice(
+                "You need 'Forge Committer' rights to push commits with another user as committer.");
+            break;
+          case FORGE_SERVER:
+            pde.setAdvice(
+                "You need 'Forge Server' rights to push merge commits authored by the server.");
+            break;
+          case MERGE:
+            pde.setAdvice(
+                "You need 'Push Merge' in addition to 'Push' rights to push merge commits.");
+            break;
+
+          case READ:
+            pde.setAdvice("You need 'Read' rights to fetch or clone this ref.");
+            break;
+
+          case READ_CONFIG:
+            pde.setAdvice("You need 'Read' rights on refs/meta/config to see the configuration.");
+            break;
+          case READ_PRIVATE_CHANGES:
+            pde.setAdvice("You need 'Read Private Changes' to see private changes.");
+            break;
+          case SET_HEAD:
+            pde.setAdvice("You need 'Set HEAD' rights to set the default branch.");
+            break;
+          case SKIP_VALIDATION:
+            pde.setAdvice(
+                "You need 'Forge Author', 'Forge Server', 'Forge Committer'\n"
+                    + "and 'Push Merge' rights to skip validation.");
+            break;
+          case UPDATE_BY_SUBMIT:
+            pde.setAdvice(
+                "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
+            break;
+
+          case WRITE_CONFIG:
+            pde.setAdvice("You need 'Write' rights on refs/meta/config.");
+            break;
+        }
+        throw pde;
       }
     }
 
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 48c8bff..e5392b0 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -26,7 +26,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.IdentityHashMap;
 import java.util.List;
 
@@ -88,7 +87,7 @@
         poison |= srcMap.put(sections.get(i), i) != null;
       }
 
-      Collections.sort(sections, new MostSpecificComparator(ref));
+      sections.sort(new MostSpecificComparator(ref));
 
       int[] srcIdx;
       if (isIdentityTransform(sections, srcMap)) {
diff --git a/java/com/google/gerrit/server/plugins/InstallPlugin.java b/java/com/google/gerrit/server/plugins/InstallPlugin.java
index 6404304..a79a5a6 100644
--- a/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintWriter;
@@ -94,6 +95,7 @@
   }
 
   @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
   static class Create
       implements RestCollectionCreateView<TopLevelResource, PluginResource, InstallPluginInput> {
     private final PluginLoader loader;
@@ -114,6 +116,7 @@
   }
 
   @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  @Singleton
   static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
     private final Provider<InstallPlugin> install;
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 229f394..5b80059 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
@@ -90,7 +91,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private static String getExtension(Path path) {
diff --git a/java/com/google/gerrit/server/plugins/PluginEntry.java b/java/com/google/gerrit/server/plugins/PluginEntry.java
index f7b1e82..3a6c7b2 100644
--- a/java/com/google/gerrit/server/plugins/PluginEntry.java
+++ b/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.plugins;
 
+import static java.util.Comparator.comparing;
+
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Map;
@@ -28,13 +30,7 @@
 public class PluginEntry {
   public static final String ATTR_CHARACTER_ENCODING = "Character-Encoding";
   public static final String ATTR_CONTENT_TYPE = "Content-Type";
-  public static final Comparator<PluginEntry> COMPARATOR_BY_NAME =
-      new Comparator<PluginEntry>() {
-        @Override
-        public int compare(PluginEntry a, PluginEntry b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
+  public static final Comparator<PluginEntry> COMPARATOR_BY_NAME = comparing(PluginEntry::getName);
 
   private static final Map<Object, String> EMPTY_ATTRS = Collections.emptyMap();
   private static final Optional<Long> NO_SIZE = Optional.empty();
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index effd51a..8bc04a3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -274,14 +274,15 @@
   private void attachItem(
       Map<TypeLiteral<?>, DynamicItem<?>> items, @Nullable Injector src, Plugin plugin) {
     for (RegistrationHandle h :
-        PrivateInternals_DynamicTypes.attachItems(src, items, plugin.getName())) {
+        PrivateInternals_DynamicTypes.attachItems(src, plugin.getName(), items)) {
       plugin.add(h);
     }
   }
 
   private void attachSet(
       Map<TypeLiteral<?>, DynamicSet<?>> sets, @Nullable Injector src, Plugin plugin) {
-    for (RegistrationHandle h : PrivateInternals_DynamicTypes.attachSets(src, sets)) {
+    for (RegistrationHandle h :
+        PrivateInternals_DynamicTypes.attachSets(src, plugin.getName(), sets)) {
       plugin.add(h);
     }
   }
@@ -434,7 +435,7 @@
           oi.remove();
           replace(newPlugin, h2, b);
         } else {
-          newPlugin.add(set.add(b.getKey(), b.getProvider()));
+          newPlugin.add(set.add(newPlugin.getName(), b.getKey(), b.getProvider()));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
index 0bef1e5..4d89482 100644
--- a/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
@@ -60,7 +61,7 @@
 
   @Override
   public String getProviderPluginName() {
-    return "gerrit";
+    return PluginName.GERRIT;
   }
 
   private ServerPluginProvider providerOf(Path srcPath) {
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
index 5d208f3..eb451fd 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheClock.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.Executors;
@@ -53,13 +54,14 @@
       // Start with generation 1 (to avoid magic 0 below).
       generation.set(1);
       executor =
-          Executors.newScheduledThreadPool(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat("ProjectCacheClock-%d")
-                  .setDaemon(true)
-                  .setPriority(Thread.MIN_PRIORITY)
-                  .build());
+          new LoggingContextAwareScheduledExecutorService(
+              Executors.newScheduledThreadPool(
+                  1,
+                  new ThreadFactoryBuilder()
+                      .setNameFormat("ProjectCacheClock-%d")
+                      .setDaemon(true)
+                      .setPriority(Thread.MIN_PRIORITY)
+                      .build()));
       @SuppressWarnings("unused") // Runnable already handles errors
       Future<?> possiblyIgnoredError =
           executor.scheduleAtFixedRate(
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index df80e35..dd6ce56 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -178,6 +178,7 @@
   @Override
   public void evict(Project.NameKey p) throws IOException {
     if (p != null) {
+      logger.atFine().log("Evict project '%s'", p.get());
       byName.invalidate(p.get());
     }
     indexer.get().index(p);
@@ -269,11 +270,12 @@
 
     @Override
     public ProjectState load(String projectName) throws Exception {
+      logger.atFine().log("Loading project %s", projectName);
       long now = clock.read();
       Project.NameKey key = new Project.NameKey(projectName);
       try (Repository git = mgr.openRepository(key)) {
         ProjectConfig cfg = new ProjectConfig(key);
-        cfg.load(git);
+        cfg.load(key, git);
 
         ProjectState state = projectStateFactory.create(cfg);
         state.initLastCheck(now);
@@ -298,6 +300,7 @@
 
     @Override
     public ImmutableSortedSet<Project.NameKey> load(ListKey key) throws Exception {
+      logger.atFine().log("Loading project list");
       return ImmutableSortedSet.copyOf(mgr.list());
     }
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 7ebbc51..10cf2de 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -19,10 +19,11 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -43,10 +44,11 @@
   public void start() {
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
-      ThreadPoolExecutor pool =
-          new ScheduledThreadPoolExecutor(
-              config.getInt("cache", "projects", "loadThreads", cpus),
-              new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
+      ExecutorService pool =
+          new LoggingContextAwareExecutorService(
+              new ScheduledThreadPoolExecutor(
+                  config.getInt("cache", "projects", "loadThreads", cpus),
+                  new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build()));
       Thread scheduler =
           new Thread(
               () -> {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index e3638b6..bccc415 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.common.data.Permission.isPermission;
 import static com.google.gerrit.reviewdb.client.Project.DEFAULT_SUBMIT_TYPE;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
@@ -25,6 +27,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -69,6 +72,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -77,6 +81,8 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
@@ -86,6 +92,7 @@
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
+  public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
@@ -158,7 +165,6 @@
 
   private static final Pattern EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN = Pattern.compile("[, \t]{1,}");
 
-  private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
   private GroupList groupList;
@@ -239,6 +245,20 @@
     this.projectName = projectName;
   }
 
+  public void load(Repository repo) throws IOException, ConfigInvalidException {
+    super.load(projectName, repo);
+  }
+
+  public void load(Repository repo, @Nullable ObjectId revision)
+      throws IOException, ConfigInvalidException {
+    super.load(projectName, repo, revision);
+  }
+
+  public void load(RevWalk rw, @Nullable ObjectId revision)
+      throws IOException, ConfigInvalidException {
+    super.load(projectName, rw, revision);
+  }
+
   public Project.NameKey getName() {
     return projectName;
   }
@@ -437,10 +457,7 @@
     return rulesId;
   }
 
-  /**
-   * @return the maxObjectSizeLimit for this project, if set. Zero if this project doesn't define
-   *     own maxObjectSizeLimit.
-   */
+  /** @return the maxObjectSizeLimit configured on this project, or zero if not configured. */
   public long getMaxObjectSizeLimit() {
     return maxObjectSizeLimit;
   }
@@ -867,6 +884,8 @@
       }
       label.setAllowPostSubmit(
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
+      label.setIgnoreSelfApproval(
+          rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
       label.setCopyMinScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
@@ -1149,21 +1168,20 @@
 
   private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
     for (NotifyConfig nc : sort(notifySections.values())) {
-      List<String> email = new ArrayList<>();
-      for (GroupReference gr : nc.getGroups()) {
-        if (gr.getUUID() != null) {
-          keepGroups.add(gr.getUUID());
-        }
-        email.add(new PermissionRule(gr).asString(false));
-      }
-      Collections.sort(email);
+      nc.getGroups()
+          .stream()
+          .map(gr -> gr.getUUID())
+          .filter(Objects::nonNull)
+          .forEach(keepGroups::add);
+      List<String> email =
+          nc.getGroups()
+              .stream()
+              .map(gr -> new PermissionRule(gr).asString(false))
+              .sorted()
+              .collect(toList());
 
-      List<String> addrs = new ArrayList<>();
-      for (Address addr : nc.getAddresses()) {
-        addrs.add(addr.toString());
-      }
-      Collections.sort(addrs);
-      email.addAll(addrs);
+      // Separate stream operation so that emails list contains 2 sorted sub-lists.
+      nc.getAddresses().stream().map(Address::toString).sorted().forEach(email::add);
 
       set(rc, NOTIFY, nc.getName(), KEY_HEADER, nc.getHeader(), NotifyConfig.Header.BCC);
       if (email.isEmpty()) {
@@ -1307,6 +1325,13 @@
           rc,
           LABEL,
           name,
+          KEY_IGNORE_SELF_APPROVAL,
+          label.ignoreSelfApproval(),
+          LabelType.DEF_IGNORE_SELF_APPROVAL);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_MIN_SCORE,
           label.isCopyMinScore(),
           LabelType.DEF_COPY_MIN_SCORE);
@@ -1437,10 +1462,8 @@
     validationErrors.add(error);
   }
 
-  private static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
-    ArrayList<T> r = new ArrayList<>(m);
-    Collections.sort(r);
-    return r;
+  private static <T extends Comparable<? super T>> ImmutableList<T> sort(Collection<T> m) {
+    return m.stream().sorted().collect(toImmutableList());
   }
 
   public boolean hasLegacyPermissions() {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 726e513..a9b19d9 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -46,6 +47,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -87,6 +89,8 @@
   private final ProjectConfig config;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
+  private final long globalMaxObjectSizeLimit;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   /** Last system time the configuration's revision was examined. */
   private volatile long lastCheckGeneration;
@@ -107,14 +111,15 @@
 
   @Inject
   public ProjectState(
-      final SitePaths sitePaths,
-      final ProjectCache projectCache,
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      final GitRepositoryManager gitMgr,
-      final List<CommentLinkInfo> commentLinks,
-      final CapabilityCollection.Factory limitsFactory,
-      @Assisted final ProjectConfig config) {
+      SitePaths sitePaths,
+      ProjectCache projectCache,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr,
+      List<CommentLinkInfo> commentLinks,
+      CapabilityCollection.Factory limitsFactory,
+      TransferConfig transferConfig,
+      @Assisted ProjectConfig config) {
     this.sitePaths = sitePaths;
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -128,6 +133,8 @@
         isAllProjects
             ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
             : null;
+    this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
+    this.inheritProjectMaxObjectSizeLimit = transferConfig.getInheritProjectMaxObjectSizeLimit();
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -225,7 +232,7 @@
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
     try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(git, config.getRevision());
+      cfg.load(getNameKey(), git, config.getRevision());
     } catch (IOException | ConfigInvalidException e) {
       logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
     }
@@ -260,6 +267,60 @@
     }
   }
 
+  public static class EffectiveMaxObjectSizeLimit {
+    public long value;
+    public String summary;
+  }
+
+  private static final String MAY_NOT_SET = "This project may not set a higher limit.";
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_PARENT = "Inherited from parent project '%s'.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_PARENT =
+      "Overridden by parent project '%s'. " + MAY_NOT_SET;
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_GLOBAL = "Inherited from the global config.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_GLOBAL =
+      "Overridden by the global config. " + MAY_NOT_SET;
+
+  public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
+    EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
+
+    result.value = config.getMaxObjectSizeLimit();
+
+    if (inheritProjectMaxObjectSizeLimit) {
+      for (ProjectState parent : parents()) {
+        long parentValue = parent.config.getMaxObjectSizeLimit();
+        if (parentValue > 0 && result.value > 0) {
+          if (parentValue < result.value) {
+            result.value = parentValue;
+            result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+          }
+        } else if (parentValue > 0) {
+          result.value = parentValue;
+          result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+        }
+      }
+    }
+
+    if (globalMaxObjectSizeLimit > 0 && result.value > 0) {
+      if (globalMaxObjectSizeLimit < result.value) {
+        result.value = globalMaxObjectSizeLimit;
+        result.summary = OVERRIDDEN_BY_GLOBAL;
+      }
+    } else if (globalMaxObjectSizeLimit > result.value) {
+      // zero means "no limit", in this case the max is more limiting
+      result.value = globalMaxObjectSizeLimit;
+      result.summary = INHERITED_FROM_GLOBAL;
+    }
+    return result;
+  }
+
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
new file mode 100644
index 0000000..e61c5df
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeIdPredicate;
+import com.google.gerrit.server.query.change.CommitPredicate;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ProjectsConsistencyChecker {
+  @VisibleForTesting public static final int AUTO_CLOSE_MAX_COMMITS_LIMIT = 10000;
+
+  private final GitRepositoryManager repoManager;
+  private final RetryHelper retryHelper;
+  private final Provider<InternalChangeQuery> changeQueryProvider;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  ProjectsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      RetryHelper retryHelper,
+      Provider<InternalChangeQuery> changeQueryProvider,
+      ChangeJson.Factory changeJsonFactory,
+      IndexConfig indexConfig) {
+    this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
+    this.changeQueryProvider = changeQueryProvider;
+    this.changeJsonFactory = changeJsonFactory;
+    this.indexConfig = indexConfig;
+  }
+
+  public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
+      throws IOException, OrmException, RestApiException {
+    CheckProjectResultInfo r = new CheckProjectResultInfo();
+    if (input.autoCloseableChangesCheck != null) {
+      r.autoCloseableChangesCheckResult =
+          checkForAutoCloseableChanges(projectName, input.autoCloseableChangesCheck);
+    }
+    return r;
+  }
+
+  private AutoCloseableChangesCheckResult checkForAutoCloseableChanges(
+      Project.NameKey projectName, AutoCloseableChangesCheckInput input)
+      throws IOException, OrmException, RestApiException {
+    AutoCloseableChangesCheckResult r = new AutoCloseableChangesCheckResult();
+    if (Strings.isNullOrEmpty(input.branch)) {
+      throw new BadRequestException("branch is required");
+    }
+
+    boolean fix = input.fix != null ? input.fix : false;
+
+    if (input.maxCommits != null && input.maxCommits > AUTO_CLOSE_MAX_COMMITS_LIMIT) {
+      throw new BadRequestException(
+          "max commits can at most be set to " + AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    }
+    int maxCommits = input.maxCommits != null ? input.maxCommits : AUTO_CLOSE_MAX_COMMITS_LIMIT;
+
+    // Result that we want to return to the client.
+    List<ChangeInfo> autoCloseableChanges = new ArrayList<>();
+
+    // Remember the change IDs of all changes that we already included into the result, so that we
+    // can avoid including the same change twice.
+    Set<Change.Id> seenChanges = new HashSet<>();
+
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      String branch = RefNames.fullName(input.branch);
+      Ref ref = repo.exactRef(branch);
+      if (ref == null) {
+        throw new UnprocessableEntityException(
+            String.format("branch '%s' not found", input.branch));
+      }
+
+      rw.reset();
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+
+      // Cache the SHA1's of all merged commits. We need this for knowing which commit merged the
+      // change when auto-closing changes by commit.
+      List<ObjectId> mergedSha1s = new ArrayList<>();
+
+      // Cache the Change-Id to commit SHA1 mapping for all Change-Id's that we find in merged
+      // commits. We need this for knowing which commit merged the change when auto-closing
+      // changes by Change-Id.
+      Map<Change.Key, ObjectId> changeIdToMergedSha1 = new HashMap<>();
+
+      // Base predicate which is fixed for every change query.
+      Predicate<ChangeData> basePredicate =
+          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+
+      int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
+
+      // List of predicates by which we want to find open changes for the branch. These predicates
+      // will be combined with the 'or' operator.
+      List<Predicate<ChangeData>> predicates = new ArrayList<>(maxLeafPredicates);
+
+      RevCommit commit;
+      int skippedCommits = 0;
+      int walkedCommits = 0;
+      while ((commit = rw.next()) != null) {
+        if (input.skipCommits != null && skippedCommits < input.skipCommits) {
+          skippedCommits++;
+          continue;
+        }
+
+        if (walkedCommits >= maxCommits) {
+          break;
+        }
+        walkedCommits++;
+
+        ObjectId commitId = commit.copy();
+        mergedSha1s.add(commitId);
+
+        // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
+        List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+
+        // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
+        // the commit.
+        int newPredicatesCount = changeIds.size() + 1;
+
+        // We accumulated the max number of query terms that can be used in one query, execute
+        // the query and start a new one.
+        if (predicates.size() + newPredicatesCount > maxLeafPredicates) {
+          autoCloseableChanges.addAll(
+              executeQueryAndAutoCloseChanges(
+                  basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+          mergedSha1s.clear();
+          changeIdToMergedSha1.clear();
+          predicates.clear();
+
+          if (newPredicatesCount > maxLeafPredicates) {
+            // Whee, a single commit generates more than maxLeafPredicates predicates. Give up.
+            throw new ResourceConflictException(
+                String.format(
+                    "commit %s contains more Change-Ids than we can handle", commit.name()));
+          }
+        }
+
+        changeIds.forEach(
+            changeId -> {
+              // It can happen that there are multiple merged commits with the same Change-Id
+              // footer (e.g. if a change was cherry-picked to a stable branch stable branch which
+              // then got merged back into master, or just by directly pushing several commits
+              // with the same Change-Id). In this case it is hard to say which of the commits
+              // should be used to auto-close an open change with the same Change-Id (and branch).
+              // Possible approaches are:
+              // 1. use the oldest commit with that Change-Id to auto-close the change
+              // 2. use the newest commit with that Change-Id to auto-close the change
+              // Possibility 1. has the disadvantage that the commit may have been merged before
+              // the change was created in which case it is strange how it could auto-close the
+              // change. Also this strategy would require to walk all commits since otherwise we
+              // cannot be sure that we have seen the oldest commit with that Change-Id.
+              // Possibility 2 has the disadvantage that it doesn't produce the same result as if
+              // auto-closing on push would have worked, since on direct push the first commit with
+              // a Change-Id of an open change would have closed that change. Also for this we
+              // would need to consider all commits that are skipped.
+              // Since both possibilities are not perfect and require extra effort we choose the
+              // easiest approach, which is use the newest commit with that Change-Id that we have
+              // seen (this means we ignore skipped commits). This should be okay since the
+              // important thing for callers is that auto-closable changes are closed. Which of the
+              // commits is used to auto-close a change if there are several candidates is of minor
+              // importance and hence can be non-deterministic.
+              Change.Key changeKey = new Change.Key(changeId);
+              if (!changeIdToMergedSha1.containsKey(changeKey)) {
+                changeIdToMergedSha1.put(changeKey, commitId);
+              }
+
+              // Find changes that have a matching Change-Id.
+              predicates.add(new ChangeIdPredicate(changeId));
+            });
+
+        // Find changes that have a matching commit.
+        predicates.add(new CommitPredicate(commit.name()));
+      }
+
+      if (predicates.size() > 0) {
+        // Execute the query with the remaining predicates that were collected.
+        autoCloseableChanges.addAll(
+            executeQueryAndAutoCloseChanges(
+                basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+      }
+    }
+
+    r.autoCloseableChanges = autoCloseableChanges;
+    return r;
+  }
+
+  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+      Predicate<ChangeData> basePredicate,
+      Set<Change.Id> seenChanges,
+      List<Predicate<ChangeData>> predicates,
+      boolean fix,
+      Map<Change.Key, ObjectId> changeIdToMergedSha1,
+      List<ObjectId> mergedSha1s)
+      throws OrmException {
+    if (predicates.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    try {
+      List<ChangeData> queryResult =
+          retryHelper.execute(
+              ActionType.INDEX_QUERY,
+              () -> {
+                // Execute the query.
+                return changeQueryProvider
+                    .get()
+                    .setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                    .query(and(basePredicate, or(predicates)));
+              },
+              OrmException.class::isInstance);
+
+      // Result for this query that we want to return to the client.
+      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+
+      for (ChangeData autoCloseableChange : queryResult) {
+        // Skip changes that we have already processed, either by this query or by
+        // earlier queries.
+        if (seenChanges.add(autoCloseableChange.getId())) {
+          retryHelper.execute(
+              ActionType.CHANGE_UPDATE,
+              () -> {
+                // Auto-close by change
+                if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
+                  autoCloseableChangesByBranch.add(
+                      changeJson(
+                              fix, changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
+                          .format(autoCloseableChange));
+                  return null;
+                }
+
+                // Auto-close by commit
+                for (ObjectId patchSetSha1 :
+                    autoCloseableChange
+                        .patchSets()
+                        .stream()
+                        .map(ps -> ObjectId.fromString(ps.getRevision().get()))
+                        .collect(toSet())) {
+                  if (mergedSha1s.contains(patchSetSha1)) {
+                    autoCloseableChangesByBranch.add(
+                        changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                    break;
+                  }
+                }
+                return null;
+              },
+              OrmException.class::isInstance);
+        }
+      }
+
+      return autoCloseableChangesByBranch;
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, OrmException.class);
+      throw new OrmException(e);
+    }
+  }
+
+  private ChangeJson changeJson(Boolean fix, ObjectId mergedAs) {
+    ChangeJson changeJson = changeJsonFactory.create(ListChangesOption.CHECK);
+    if (fix != null && fix.booleanValue()) {
+      FixInput fixInput = new FixInput();
+      fixInput.expectMergedAs = mergedAs.name();
+      changeJson.fix(fixInput);
+    }
+    return changeJson;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index cc9fc0d..bd7b7fe 100644
--- a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
@@ -21,6 +22,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final AccountControl accountControl;
 
   public AccountIsVisibleToPredicate(AccountControl accountControl) {
@@ -30,7 +33,11 @@
 
   @Override
   public boolean match(AccountState accountState) throws OrmException {
-    return accountControl.canSee(accountState);
+    boolean canSee = accountControl.canSee(accountState);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visisble account: %s", accountState);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 27baef1..f81ea15 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -74,10 +74,11 @@
     try {
       ProjectState projectState = projectCache.checkedGet(cd.project());
       if (projectState == null) {
-        logger.atInfo().log("No such project: %s", cd.project());
+        logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
         return false;
       }
       if (!projectState.statePermitsRead()) {
+        logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
         return false;
       }
     } catch (IOException e) {
@@ -94,11 +95,13 @@
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
         logger.atWarning().withCause(e).log(
-            "Skipping change %s because the corresponding repository was not found", cd.getId());
+            "Filter out change %s because the corresponding repository %s was not found",
+            cd, cd.project());
         return false;
       }
       throw new OrmException("unable to check permissions on change " + cd.getId(), e);
     } catch (AuthException e) {
+      logger.atFine().log("Filter out non-visisble change: %s", cd);
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index f8f892b..5667869 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -1107,7 +1107,7 @@
   public Predicate<ChangeData> query(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
-      q.load(git);
+      q.load(args.allUsersName, git);
       String query = q.getQueryList().getQuery(name);
       if (query != null) {
         return parse(query);
@@ -1131,7 +1131,7 @@
   public Predicate<ChangeData> destination(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
-      d.load(git);
+      d.load(args.allUsersName, git);
       Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
diff --git a/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
index 52904f7..42f5b13 100644
--- a/java/com/google/gerrit/server/query/change/ConflictKey.java
+++ b/java/com/google/gerrit/server/query/change/ConflictKey.java
@@ -20,10 +20,10 @@
 import com.google.common.base.Enums;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
index 0b8c5ee..426c5d6 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 3c62848..495d27c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -138,7 +138,21 @@
   }
 
   public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
+    return query(byBranchKeyPred(branch, key));
+  }
+
+  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key)
+      throws OrmException {
+    return query(and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchKeyOpenPred(
+      Project.NameKey project, String branch, Change.Key key) {
+    return and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open());
+  }
+
+  private static Predicate<ChangeData> byBranchKeyPred(Branch.NameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.getParentKey()), change(key));
   }
 
   public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
@@ -264,13 +278,28 @@
 
   public List<ChangeData> byBranchCommit(String project, String branch, String hash)
       throws OrmException {
-    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
+    return query(byBranchCommitPred(project, branch, hash));
   }
 
   public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
     return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
   }
 
+  public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash)
+      throws OrmException {
+    return query(and(byBranchCommitPred(project, branch, hash), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchCommitOpenPred(
+      Project.NameKey project, String branch, String hash) {
+    return and(byBranchCommitPred(project.get(), branch, hash), open());
+  }
+
+  private static Predicate<ChangeData> byBranchCommitPred(
+      String project, String branch, String hash) {
+    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+  }
+
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index ffa59c2..144a81c 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<InternalGroup> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final GroupControl.GenericFactory groupControlFactory;
   protected final CurrentUser user;
 
@@ -37,7 +40,11 @@
   @Override
   public boolean match(InternalGroup group) throws OrmException {
     try {
-      return groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      boolean canSee = groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      if (!canSee) {
+        logger.atFine().log("Filter out non-visisble group: %s", group.getGroupUUID());
+      }
+      return canSee;
     } catch (NoSuchGroupException e) {
       // Ignored
       return false;
diff --git a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
index dc567ea..b1c5af0 100644
--- a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class ProjectIsVisibleToPredicate extends IsVisibleToPredicate<ProjectData> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final PermissionBackend permissionBackend;
   protected final CurrentUser user;
 
@@ -36,13 +39,19 @@
   @Override
   public boolean match(ProjectData pd) throws OrmException {
     if (!pd.getProject().getState().permitsRead()) {
+      logger.atFine().log("Filter out non-readable project: %s", pd);
       return false;
     }
 
-    return permissionBackend
-        .user(user)
-        .project(pd.getProject().getNameKey())
-        .testOrFalse(ProjectPermission.ACCESS);
+    boolean canSee =
+        permissionBackend
+            .user(user)
+            .project(pd.getProject().getNameKey())
+            .testOrFalse(ProjectPermission.ACCESS);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visible project: %s", pd);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index dc447de..0e8eb70 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -55,6 +55,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -63,6 +64,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
+@Singleton
 public class CreateAccount
     implements RestCollectionCreateView<TopLevelResource, AccountResource, AccountInput> {
   private final Sequences seq;
@@ -113,8 +115,7 @@
       throw new BadRequestException("username must match URL");
     }
     if (!ExternalId.isValidUsername(username)) {
-      throw new BadRequestException(
-          "Username '" + username + "' must contain only letters, numbers, _, - or .");
+      throw new BadRequestException("Invalid username '" + username + "'");
     }
 
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index cdfb1fa..e4e8525 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -44,9 +44,11 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+@Singleton
 public class CreateEmail
     implements RestCollectionCreateView<AccountResource, AccountResource.Email, EmailInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index 7889f6e..5c466bf 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -26,8 +26,8 @@
 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.BinaryResult;
 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.RestReadView;
 import com.google.gerrit.server.CurrentUser;
@@ -172,9 +172,9 @@
     }
 
     @Override
-    public BinaryResult apply(Capability resource) throws ResourceNotFoundException {
+    public Response<String> apply(Capability resource) throws ResourceNotFoundException {
       permissionBackend.checkUsesDefaultCapabilities();
-      return BinaryResult.create("ok\n");
+      return Response.ok("ok");
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 85262ee..ed3347f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -25,10 +28,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
+import java.util.Objects;
 
 @Singleton
 public class GetEmails implements RestReadView<AccountResource> {
@@ -47,24 +48,19 @@
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
+    return rsrc.getUser()
+        .getEmailAddresses()
+        .stream()
+        .filter(Objects::nonNull)
+        .map(e -> toEmailInfo(rsrc, e))
+        .sorted(comparing((EmailInfo e) -> e.email))
+        .collect(toList());
+  }
 
-    List<EmailInfo> emails = new ArrayList<>();
-    for (String email : rsrc.getUser().getEmailAddresses()) {
-      if (email != null) {
-        EmailInfo e = new EmailInfo();
-        e.email = email;
-        e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-        emails.add(e);
-      }
-    }
-    Collections.sort(
-        emails,
-        new Comparator<EmailInfo>() {
-          @Override
-          public int compare(EmailInfo a, EmailInfo b) {
-            return a.email.compareTo(b.email);
-          }
-        });
-    return emails;
+  private static EmailInfo toEmailInfo(AccountResource rsrc, String email) {
+    EmailInfo e = new EmailInfo();
+    e.email = email;
+    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
+    return e;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 112bb24..61021be 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,11 +38,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -67,31 +65,28 @@
 
     Account.Id accountId = rsrc.getUser().getAccountId();
     AccountState account = accounts.get(accountId).orElseThrow(ResourceNotFoundException::new);
-    List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
-    for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e :
-        account.getProjectWatches().entrySet()) {
-      ProjectWatchInfo pwi = new ProjectWatchInfo();
-      pwi.filter = e.getKey().filter();
-      pwi.project = e.getKey().project().get();
-      pwi.notifyAbandonedChanges = toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
-      pwi.notifyNewChanges = toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
-      pwi.notifyNewPatchSets = toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
-      pwi.notifySubmittedChanges = toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
-      pwi.notifyAllComments = toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
-      projectWatchInfos.add(pwi);
-    }
-    Collections.sort(
-        projectWatchInfos,
-        new Comparator<ProjectWatchInfo>() {
-          @Override
-          public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
-            return ComparisonChain.start()
-                .compare(pwi1.project, pwi2.project)
-                .compare(Strings.nullToEmpty(pwi1.filter), Strings.nullToEmpty(pwi2.filter))
-                .result();
-          }
-        });
-    return projectWatchInfos;
+    return account
+        .getProjectWatches()
+        .entrySet()
+        .stream()
+        .map(e -> toProjectWatchInfo(e.getKey(), e.getValue()))
+        .sorted(
+            comparing((ProjectWatchInfo pwi) -> pwi.project)
+                .thenComparing(pwi -> Strings.nullToEmpty(pwi.filter)))
+        .collect(toList());
+  }
+
+  private static ProjectWatchInfo toProjectWatchInfo(
+      ProjectWatchKey key, ImmutableSet<NotifyType> watchTypes) {
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.filter = key.filter();
+    pwi.project = key.project().get();
+    pwi.notifyAbandonedChanges = toBoolean(watchTypes.contains(NotifyType.ABANDONED_CHANGES));
+    pwi.notifyNewChanges = toBoolean(watchTypes.contains(NotifyType.NEW_CHANGES));
+    pwi.notifyNewPatchSets = toBoolean(watchTypes.contains(NotifyType.NEW_PATCHSETS));
+    pwi.notifySubmittedChanges = toBoolean(watchTypes.contains(NotifyType.SUBMITTED_CHANGES));
+    pwi.notifyAllComments = toBoolean(watchTypes.contains(NotifyType.ALL_COMMENTS));
+    return pwi;
   }
 
   private static Boolean toBoolean(boolean value) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index e92abe1..a562592 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -35,7 +37,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -96,9 +97,7 @@
         list.add(o);
       }
 
-      for (List<T> list : out.values()) {
-        Collections.sort(list, COMMENT_INFO_ORDER);
-      }
+      out.values().forEach(l -> l.sort(COMMENT_INFO_ORDER));
 
       if (loader != null) {
         loader.fill();
@@ -106,13 +105,14 @@
       return out;
     }
 
-    public List<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
+    public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
 
-      List<T> out =
-          FluentIterable.from(comments)
-              .transform(c -> toInfo(c, loader))
-              .toSortedList(COMMENT_INFO_ORDER);
+      ImmutableList<T> out =
+          Streams.stream(comments)
+              .map(c -> toInfo(c, loader))
+              .sorted(COMMENT_INFO_ORDER)
+              .collect(toImmutableList());
 
       if (loader != null) {
         loader.fill();
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index db8ef0c..dbd0ccf 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -61,7 +62,7 @@
         .format(listComments(rsrc));
   }
 
-  public List<CommentInfo> getComments(RevisionResource rsrc)
+  public ImmutableList<CommentInfo> getComments(RevisionResource rsrc)
       throws OrmException, PermissionBackendException {
     return commentJson
         .get()
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 66138ab..99366aa 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.RobotComment;
@@ -52,7 +53,7 @@
         .format(listComments(rsrc));
   }
 
-  public List<RobotCommentInfo> getComments(RevisionResource rsrc)
+  public ImmutableList<RobotCommentInfo> getComments(RevisionResource rsrc)
       throws OrmException, PermissionBackendException {
     return commentJson
         .get()
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 47c6970..6b7a708 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -107,8 +107,13 @@
       ProjectState projectState,
       List<Account.Id> candidateList)
       throws OrmException, IOException, ConfigInvalidException {
+    logger.atFine().log("Candidates %s", candidateList);
+
     String query = suggestReviewers.getQuery();
+    logger.atFine().log("query: %s", query);
+
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+    logger.atFine().log("base weight: %s", baseWeight);
 
     Map<Account.Id, MutableDouble> reviewerScores;
     if (Strings.isNullOrEmpty(query)) {
@@ -116,6 +121,7 @@
     } else {
       reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
     }
+    logger.atFine().log("Base reviewer scores: %s", reviewerScores);
 
     // Send the query along with a candidate list to all plugins and merge the
     // results. Plugins don't necessarily need to use the candidates list, they
@@ -163,6 +169,7 @@
           }
         }
       }
+      logger.atFine().log("Reviewer scores: %s", reviewerScores);
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
       return ImmutableList.of();
@@ -170,12 +177,20 @@
 
     if (changeNotes != null) {
       // Remove change owner
-      reviewerScores.remove(changeNotes.getChange().getOwner());
+      if (reviewerScores.remove(changeNotes.getChange().getOwner()) != null) {
+        logger.atFine().log("Remove change owner %s", changeNotes.getChange().getOwner());
+      }
 
       // Remove existing reviewers
-      reviewerScores
-          .keySet()
-          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
+      approvalsUtil
+          .getReviewers(dbProvider.get(), changeNotes)
+          .byState(REVIEWER)
+          .forEach(
+              r -> {
+                if (reviewerScores.remove(r) != null) {
+                  logger.atFine().log("Remove existing reviewer %s", r);
+                }
+              });
     }
 
     // Sort results
@@ -185,6 +200,7 @@
             .stream()
             .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
+    logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 57ff0a3..3becf24 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.index.account.AccountField;
@@ -228,24 +229,30 @@
         // For performance reasons we don't use AccountQueryProvider as it would always load the
         // complete account from the cache (or worse, from NoteDb) even though we only need the ID
         // which we can directly get from the returned results.
+        Predicate<AccountState> pred =
+            Predicate.and(
+                AccountPredicates.isActive(),
+                accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+        logger.atFine().log("accounts index query: %s", pred);
         ResultSet<FieldBundle> result =
             accountIndexes
                 .getSearchIndex()
                 .getSource(
-                    Predicate.and(
-                        AccountPredicates.isActive(),
-                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())),
+                    pred,
                     QueryOptions.create(
                         indexConfig,
                         0,
                         suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
                         ImmutableSet.of(AccountField.ID.getName())))
                 .readRaw();
-        return result
-            .toList()
-            .stream()
-            .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
-            .collect(toList());
+        List<Account.Id> matches =
+            result
+                .toList()
+                .stream()
+                .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+                .collect(toList());
+        logger.atFine().log("Matches: %s", matches);
+        return matches;
       } catch (QueryParseException e) {
         return ImmutableList.of();
       }
@@ -374,8 +381,10 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
+    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
     if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+      logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
 
@@ -383,6 +392,7 @@
       Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
+        logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
         return result;
       }
 
@@ -392,6 +402,11 @@
       }
 
       boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+      if (needsConfirmation) {
+        logger.atFine().log(
+            "group %s needs confirmation to be added as reviewer, it has %d members",
+            group.getUUID(), result.size);
+      }
 
       // require that at least one member in the group can see the change
       for (Account account : members) {
@@ -401,9 +416,12 @@
           } else {
             result.allowed = true;
           }
+          logger.atFine().log("Suggest group %s", group.getUUID());
           return result;
         }
       }
+      logger.atFine().log(
+          "Ignore group %s since none of its members can see the change", group.getUUID());
     } catch (NoSuchProjectException e) {
       return result;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
index 1b50834..8aac92c 100644
--- a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
@@ -18,8 +18,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.PrivateStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -44,19 +47,23 @@
   }
 
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final boolean isPrivate;
   private final Input input;
   private final PrivateStateChanged privateStateChanged;
 
   private Change change;
+  private PatchSet ps;
 
   @Inject
   SetPrivateOp(
       PrivateStateChanged privateStateChanged,
+      PatchSetUtil psUtil,
       @Assisted ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
       @Assisted Input input) {
     this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.isPrivate = isPrivate;
     this.input = input;
     this.privateStateChanged = privateStateChanged;
@@ -65,6 +72,8 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
     change = ctx.getChange();
+    ChangeNotes notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), notes, change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
@@ -75,7 +84,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
   }
 
   private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 8b39e8e..b1d49f2 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.config.GerritConfigListenerHelper.acceptIfChanged;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.ConfigKey;
@@ -27,6 +28,8 @@
 import org.kohsuke.args4j.Option;
 
 public class SuggestReviewers {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
   protected final Provider<ReviewDb> dbProvider;
@@ -101,6 +104,8 @@
             "addreviewer",
             "maxWithoutConfirmation",
             PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+
+    logger.atFine().log("AccountVisibility: %s", av.name());
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/config/CachesCollection.java b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
index 152fef9..a4b8802 100644
--- a/java/com/google/gerrit/server/restapi/config/CachesCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -66,7 +67,7 @@
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
-    String pluginName = "gerrit";
+    String pluginName = PluginName.GERRIT;
     int i = cacheName.lastIndexOf('-');
     if (i != -1) {
       pluginName = cacheName.substring(0, i);
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 4a89dfc..13c2818 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -44,7 +44,7 @@
   public DiffPreferencesInfo apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultDiffPreferences(git);
+      return Preferences.readDefaultDiffPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
index 44466d3..2ec547b 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -43,7 +43,7 @@
   public EditPreferencesInfo apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Preferences.readDefaultEditPreferences(git);
+      return Preferences.readDefaultEditPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
index e0c54d4..4dbbc8c 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -41,7 +41,7 @@
   public GeneralPreferencesInfo apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      return Preferences.readDefaultGeneralPreferences(git);
+      return Preferences.readDefaultGeneralPreferences(allUsersName, git);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index b7931bd..66e9f90 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -64,7 +64,6 @@
 import com.google.gerrit.server.restapi.change.AllowedFormats;
 import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import java.net.MalformedURLException;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -149,44 +148,42 @@
   }
 
   @Override
-  public ServerInfo apply(ConfigResource rsrc)
-      throws MalformedURLException, PermissionBackendException {
+  public ServerInfo apply(ConfigResource rsrc) throws PermissionBackendException {
     ServerInfo info = new ServerInfo();
-    info.accounts = getAccountsInfo(accountVisibilityProvider);
-    info.auth = getAuthInfo(authConfig, realm);
-    info.change = getChangeInfo(config);
-    info.download =
-        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands, archiveFormats);
-    info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
+    info.accounts = getAccountsInfo();
+    info.auth = getAuthInfo();
+    info.change = getChangeInfo();
+    info.download = getDownloadInfo();
+    info.gerrit = getGerritInfo();
     info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
     if (Files.exists(sitePaths.site_theme)) {
       info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
     }
-    info.sshd = getSshdInfo(config);
-    info.suggest = getSuggestInfo(config);
+    info.sshd = getSshdInfo();
+    info.suggest = getSuggestInfo();
 
-    Map<String, String> urlAliases = getUrlAliasesInfo(config);
+    Map<String, String> urlAliases = getUrlAliasesInfo();
     info.urlAliases = !urlAliases.isEmpty() ? urlAliases : null;
 
-    info.user = getUserInfo(anonymousCowardName);
+    info.user = getUserInfo();
     info.receive = getReceiveInfo();
     return info;
   }
 
-  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
+  private AccountsInfo getAccountsInfo() {
     AccountsInfo info = new AccountsInfo();
     info.visibility = accountVisibilityProvider.get();
     return info;
   }
 
-  private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) throws PermissionBackendException {
+  private AuthInfo getAuthInfo() throws PermissionBackendException {
     AuthInfo info = new AuthInfo();
-    info.authType = cfg.getAuthType();
-    info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
+    info.authType = authConfig.getAuthType();
+    info.useContributorAgreements = toBoolean(authConfig.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
-    info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
+    info.switchAccountUrl = authConfig.getSwitchAccountUrl();
+    info.gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
@@ -202,22 +199,22 @@
     switch (info.authType) {
       case LDAP:
       case LDAP_BIND:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
         break;
 
       case CUSTOM_EXTENSION:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.httpPasswordUrl = cfg.getHttpPasswordUrl();
+        info.registerUrl = authConfig.getRegisterUrl();
+        info.registerText = authConfig.getRegisterText();
+        info.editFullNameUrl = authConfig.getEditFullNameUrl();
+        info.httpPasswordUrl = authConfig.getHttpPasswordUrl();
         break;
 
       case HTTP:
       case HTTP_LDAP:
-        info.loginUrl = cfg.getLoginUrl();
-        info.loginText = cfg.getLoginText();
+        info.loginUrl = authConfig.getLoginUrl();
+        info.loginText = authConfig.getLoginText();
         break;
 
       case CLIENT_SSL_CERT_LDAP:
@@ -230,40 +227,37 @@
     return info;
   }
 
-  private ChangeConfigInfo getChangeInfo(Config cfg) {
+  private ChangeConfigInfo getChangeInfo() {
     ChangeConfigInfo info = new ChangeConfigInfo();
-    info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
+    info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
     boolean hasAssigneeInIndex =
         indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
     info.showAssigneeInChangesTable =
         toBoolean(
-            cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = cfg.getInt("change", "largeChange", 500);
+            config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
+    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
-        Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
+        Optional.ofNullable(config.getString("change", null, "replyTooltip"))
+                .orElse("Reply and score")
             + " (Shortcut: a)";
     info.replyLabel =
-        Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
+        Optional.ofNullable(config.getString("change", null, "replyLabel")).orElse("Reply")
+            + "\u2026";
     info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
-    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(cfg);
+        (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
+    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(config);
     info.disablePrivateChanges =
-        toBoolean(config.getBoolean("change", null, "disablePrivateChanges", false));
+        toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
     return info;
   }
 
-  private DownloadInfo getDownloadInfo(
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands,
-      AllowedFormats archiveFormats) {
+  private DownloadInfo getDownloadInfo() {
     DownloadInfo info = new DownloadInfo();
     info.schemes = new HashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       DownloadScheme scheme = e.getProvider().get();
       if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
-        info.schemes.put(
-            e.getExportName(), getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
+        info.schemes.put(e.getExportName(), getDownloadSchemeInfo(scheme));
       }
     }
     info.archives =
@@ -271,10 +265,7 @@
     return info;
   }
 
-  private DownloadSchemeInfo getDownloadSchemeInfo(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands) {
+  private DownloadSchemeInfo getDownloadSchemeInfo(DownloadScheme scheme) {
     DownloadSchemeInfo info = new DownloadSchemeInfo();
     info.url = scheme.getUrl("${project}");
     info.isAuthRequired = toBoolean(scheme.isAuthRequired());
@@ -304,17 +295,16 @@
     return info;
   }
 
-  private GerritInfo getGerritInfo(
-      Config cfg, AllProjectsName allProjectsName, AllUsersName allUsersName) {
+  private GerritInfo getGerritInfo() {
     GerritInfo info = new GerritInfo();
     info.allProjects = allProjectsName.get();
     info.allUsers = allUsersName.get();
-    info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
-    info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
-    info.docUrl = getDocUrl(cfg);
+    info.reportBugUrl = config.getString("gerrit", null, "reportBugUrl");
+    info.reportBugText = config.getString("gerrit", null, "reportBugText");
+    info.docUrl = getDocUrl();
     info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys =
-        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+        toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.webUis = EnumSet.noneOf(UiType.class);
     info.webUis.add(UiType.POLYGERRIT);
     if (gerritOptions.enableGwtUi()) {
@@ -323,8 +313,8 @@
     return info;
   }
 
-  private String getDocUrl(Config cfg) {
-    String docUrl = cfg.getString("gerrit", null, "docUrl");
+  private String getDocUrl() {
+    String docUrl = config.getString("gerrit", null, "docUrl");
     if (Strings.isNullOrEmpty(docUrl)) {
       return null;
     }
@@ -352,18 +342,18 @@
     return info;
   }
 
-  private Map<String, String> getUrlAliasesInfo(Config cfg) {
+  private Map<String, String> getUrlAliasesInfo() {
     Map<String, String> urlAliases = new HashMap<>();
-    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+    for (String subsection : config.getSubsections(URL_ALIAS)) {
       urlAliases.put(
-          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+          config.getString(URL_ALIAS, subsection, KEY_MATCH),
+          config.getString(URL_ALIAS, subsection, KEY_TOKEN));
     }
     return urlAliases;
   }
 
-  private SshdInfo getSshdInfo(Config cfg) {
-    String[] addr = cfg.getStringList("sshd", null, "listenAddress");
+  private SshdInfo getSshdInfo() {
+    String[] addr = config.getStringList("sshd", null, "listenAddress");
     if (addr.length == 1 && isOff(addr[0])) {
       return null;
     }
@@ -376,13 +366,13 @@
         || "no".equalsIgnoreCase(listenHostname);
   }
 
-  private SuggestInfo getSuggestInfo(Config cfg) {
+  private SuggestInfo getSuggestInfo() {
     SuggestInfo info = new SuggestInfo();
-    info.from = cfg.getInt("suggest", "from", 0);
+    info.from = config.getInt("suggest", "from", 0);
     return info;
   }
 
-  private UserConfigInfo getUserInfo(String anonymousCowardName) {
+  private UserConfigInfo getUserInfo() {
     UserConfigInfo info = new UserConfigInfo();
     info.anonymousCowardName = anonymousCowardName;
     return info;
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index c0a9d71..38664fb 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
 import static com.google.gerrit.server.config.CacheResource.cacheNameOf;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.base.Joiner;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheStats;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -29,11 +31,9 @@
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
@@ -72,19 +72,17 @@
     if (format == null) {
       return getCacheInfos();
     }
-    List<String> cacheNames = new ArrayList<>();
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
-    }
-    Collections.sort(cacheNames);
-
+    Stream<String> cacheNames =
+        Streams.stream(cacheMap)
+            .map(e -> cacheNameOf(e.getPluginName(), e.getExportName()))
+            .sorted();
     if (OutputFormat.TEXT_LIST.equals(format)) {
-      return BinaryResult.create(Joiner.on('\n').join(cacheNames))
+      return BinaryResult.create(cacheNames.collect(joining("\n")))
           .base64()
           .setContentType("text/plain")
           .setCharacterEncoding(UTF_8);
     }
-    return cacheNames;
+    return cacheNames.collect(toImmutableList());
   }
 
   public enum CacheType {
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index 7b69831..f77cda4 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.restapi.config;
 
-import com.google.common.collect.ComparisonChain;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -35,8 +37,6 @@
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -106,20 +106,14 @@
   }
 
   private List<TaskInfo> getTasks() {
-    List<TaskInfo> taskInfos = workQueue.getTaskInfos(TaskInfo::new);
-    Collections.sort(
-        taskInfos,
-        new Comparator<TaskInfo>() {
-          @Override
-          public int compare(TaskInfo a, TaskInfo b) {
-            return ComparisonChain.start()
-                .compare(a.state.ordinal(), b.state.ordinal())
-                .compare(a.delay, b.delay)
-                .compare(a.command, b.command)
-                .result();
-          }
-        });
-    return taskInfos;
+    return workQueue
+        .getTaskInfos(TaskInfo::new)
+        .stream()
+        .sorted(
+            comparing((TaskInfo t) -> t.state.ordinal())
+                .thenComparing(t -> t.delay)
+                .thenComparing(t -> t.command))
+        .collect(toList());
   }
 
   public static class TaskInfo {
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index 7f9b756..57ba097 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -110,7 +111,7 @@
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
-      String pluginName = "gerrit";
+      String pluginName = PluginName.GERRIT;
       String cacheName = n;
       int i = cacheName.lastIndexOf('-');
       if (i != -1) {
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 648873d..a897e1a 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -216,6 +216,7 @@
     return result;
   }
 
+  @Singleton
   public static class CreateMember
       implements RestCollectionCreateView<GroupResource, MemberResource, Input> {
     private final AddMembers put;
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 948cb5b..ca77ebf 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -131,6 +131,7 @@
     groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
   }
 
+  @Singleton
   public static class CreateSubgroup
       implements RestCollectionCreateView<GroupResource, SubgroupResource, Input> {
     private final AddSubgroups addSubgroups;
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 2d062e9..64d515c 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -57,6 +57,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -69,6 +70,7 @@
 import org.eclipse.jgit.lib.PersonIdent;
 
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
+@Singleton
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 7af4284..dcdd8a8 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -41,7 +41,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,7 +137,7 @@
     accountLoader.fill();
 
     // sort by date and then reverse so that the newest audit event comes first
-    Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date).reversed());
+    auditEvents.sort(comparing((GroupAuditEventInfo a) -> a.date).reversed());
     return auditEvents;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index 97a260e..864b01b 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import static com.google.common.base.Strings.nullToEmpty;
+import static java.util.Comparator.comparing;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
@@ -29,8 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 @Singleton
@@ -72,18 +71,8 @@
         continue;
       }
     }
-    Collections.sort(
-        included,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-            if (cmp != 0) {
-              return cmp;
-            }
-            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-          }
-        });
+    included.sort(
+        comparing((GroupInfo g) -> nullToEmpty(g.name)).thenComparing(g -> nullToEmpty(g.id)));
     return included;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Check.java b/java/com/google/gerrit/server/restapi/project/Check.java
new file mode 100644
index 0000000..a6fd764
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Check.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class Check implements RestModifyView<ProjectResource, CheckProjectInput> {
+  private final PermissionBackend permissionBackend;
+  private final ProjectsConsistencyChecker projectsConsistencyChecker;
+
+  @Inject
+  Check(
+      PermissionBackend permissionBackend, ProjectsConsistencyChecker projectsConsistencyChecker) {
+    this.permissionBackend = permissionBackend;
+    this.projectsConsistencyChecker = projectsConsistencyChecker;
+  }
+
+  @Override
+  public CheckProjectResultInfo apply(ProjectResource rsrc, CheckProjectInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+    return projectsConsistencyChecker.check(rsrc.getNameKey(), input);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 0d52090..60b5dee 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -33,10 +33,10 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.ProjectState.EffectiveMaxObjectSizeLimit;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -48,7 +48,6 @@
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -72,14 +71,7 @@
       this.requireSignedPush = null;
     }
 
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config.getMaxObjectSizeLimit()
-            ? config.getFormattedMaxObjectSizeLimit()
-            : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue = config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
+    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
     this.defaultSubmitType = new SubmitTypeInfo();
     this.defaultSubmitType.value = projectState.getSubmitType();
@@ -114,6 +106,16 @@
     this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
+  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+    MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
+    EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
+    long value = limit.value;
+    info.value = value == 0 ? null : String.valueOf(value);
+    info.configuredValue = p.getMaxObjectSizeLimit();
+    info.summary = limit.summary;
+    return info;
+  }
+
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 8973508..62106e8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -51,6 +52,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
+@Singleton
 public class CreateBranch
     implements RestCollectionCreateView<ProjectResource, BranchResource, BranchInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 43a4f44..271848b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -72,6 +72,7 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -90,6 +91,7 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
+@Singleton
 public class CreateProject
     implements RestCollectionCreateView<TopLevelResource, ProjectResource, ProjectInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index bc34f05..dd73b0b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.gerrit.server.project.TagResource;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.TimeZone;
 import org.eclipse.jgit.api.Git;
@@ -53,6 +54,7 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+@Singleton
 public class CreateTag implements RestCollectionCreateView<ProjectResource, TagResource, TagInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index d545f92..a6b9404 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
 import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
@@ -270,6 +271,7 @@
                     || (canReadConfig
                         && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE))));
     info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
+    info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
     info.configVisible = canReadConfig || canWriteConfig;
 
     info.groups =
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index aafff9e..b3ad962 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +30,6 @@
 @Singleton
 public class GetConfig implements RestReadView<ProjectResource> {
   private final boolean serverEnableSignedPush;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -41,14 +39,12 @@
   @Inject
   public GetConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
@@ -62,7 +58,6 @@
         serverEnableSignedPush,
         resource.getProjectState(),
         resource.getUser(),
-        config,
         pluginConfigEntries,
         cfgFactory,
         allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index bf4a547..a0d2528 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -46,7 +46,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
@@ -226,7 +225,7 @@
         // Do nothing.
       }
     }
-    Collections.sort(branches, new BranchComparator());
+    branches.sort(new BranchComparator());
     return branches;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index e79fdca..f59e984 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
@@ -39,8 +40,6 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -135,14 +134,7 @@
       }
     }
 
-    Collections.sort(
-        tags,
-        new Comparator<TagInfo>() {
-          @Override
-          public int compare(TagInfo a, TagInfo b) {
-            return a.ref.compareTo(b.ref);
-          }
-        });
+    tags.sort(comparing(t -> t.ref));
 
     return new RefFilter<TagInfo>(Constants.R_TAGS)
         .start(start)
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index a57438e..0497787 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -54,6 +54,8 @@
     post(PROJECT_KIND, "check.access").to(CheckAccess.class);
     get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
+    post(PROJECT_KIND, "check").to(Check.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index db596e6..76ea0c9 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -71,7 +70,6 @@
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -86,7 +84,6 @@
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -98,7 +95,6 @@
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
@@ -168,12 +164,11 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfig);
+      ProjectState state = projectStateFactory.create(ProjectConfig.read(md));
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
           user.get(),
-          config,
           pluginConfigEntries,
           cfgFactory,
           allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 6710f6c..b38619b 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -30,10 +30,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
@@ -43,6 +45,7 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class SetParent implements RestModifyView<ProjectResource, ParentInput> {
@@ -51,6 +54,7 @@
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
+  private final boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
@@ -58,12 +62,15 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.Server updateFactory,
       AllProjectsName allProjects,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      @GerritServerConfig Config config) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
+    this.allowProjectOwnersToChangeParent =
+        config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
   }
 
   @Override
@@ -114,7 +121,11 @@
       throws AuthException, ResourceConflictException, UnprocessableEntityException,
           PermissionBackendException, BadRequestException {
     if (checkIfAdmin) {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      if (allowProjectOwnersToChangeParent) {
+        permissionBackend.user(user).project(project).check(ProjectPermission.WRITE_CONFIG);
+      } else {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
     }
 
     if (project.equals(allUsers) && !allProjects.get().equals(newParent)) {
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
new file mode 100644
index 0000000..b9ddbc6
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Rule to require an approval from a user that did not upload the current patch set or block
+ * submission.
+ */
+@Singleton
+public class IgnoreSelfApprovalRule implements SubmitRule {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String E_UNABLE_TO_FETCH_UPLOADER = "Unable to fetch uploader";
+  private static final String E_UNABLE_TO_FETCH_LABELS =
+      "Unable to fetch labels and approvals for the change";
+
+  public static class Module extends FactoryModule {
+    @Override
+    public void configure() {
+      bind(SubmitRule.class)
+          .annotatedWith(Exports.named("IgnoreSelfApprovalRule"))
+          .to(IgnoreSelfApprovalRule.class);
+    }
+  }
+
+  @Inject
+  IgnoreSelfApprovalRule() {}
+
+  @Override
+  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+    List<LabelType> labelTypes;
+    List<PatchSetApproval> approvals;
+    try {
+      labelTypes = cd.getLabelTypes().getLabelTypes();
+      approvals = cd.currentApprovals();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
+      return singletonRuleError(E_UNABLE_TO_FETCH_LABELS);
+    }
+
+    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(l -> l.ignoreSelfApproval());
+    if (!shouldIgnoreSelfApproval) {
+      // Shortcut to avoid further processing if no label should ignore uploader approvals
+      return ImmutableList.of();
+    }
+
+    Account.Id uploader;
+    try {
+      uploader = cd.currentPatchSet().getUploader();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
+      return singletonRuleError(E_UNABLE_TO_FETCH_UPLOADER);
+    }
+
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.status = SubmitRecord.Status.OK;
+    submitRecord.labels = new ArrayList<>(labelTypes.size());
+    submitRecord.requirements = new ArrayList<>();
+
+    for (LabelType t : labelTypes) {
+      if (!t.ignoreSelfApproval()) {
+        // The default rules are enough in this case.
+        continue;
+      }
+
+      LabelFunction labelFunction = t.getFunction();
+      if (labelFunction == null) {
+        continue;
+      }
+
+      Collection<PatchSetApproval> allApprovalsForLabel = filterApprovalsByLabel(approvals, t);
+      SubmitRecord.Label allApprovalsCheckResult = labelFunction.check(t, allApprovalsForLabel);
+      SubmitRecord.Label ignoreSelfApprovalCheckResult =
+          labelFunction.check(t, filterOutPositiveApprovalsOfUser(allApprovalsForLabel, uploader));
+
+      if (labelCheckPassed(allApprovalsCheckResult)
+          && !labelCheckPassed(ignoreSelfApprovalCheckResult)) {
+        // The label has a valid approval from the uploader and no other valid approval. Set the
+        // label
+        // to NOT_READY and indicate the need for non-uploader approval as requirement.
+        submitRecord.labels.add(ignoreSelfApprovalCheckResult);
+        submitRecord.status = SubmitRecord.Status.NOT_READY;
+        // Add an additional requirement to be more descriptive on why the label counts as not
+        // approved.
+        submitRecord.requirements.add(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+      }
+    }
+
+    if (submitRecord.labels.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    return ImmutableList.of(submitRecord);
+  }
+
+  private static boolean labelCheckPassed(SubmitRecord.Label label) {
+    switch (label.status) {
+      case OK:
+      case MAY:
+        return true;
+
+      case NEED:
+      case REJECT:
+      case IMPOSSIBLE:
+        return false;
+    }
+    return false;
+  }
+
+  private static Collection<SubmitRecord> singletonRuleError(String reason) {
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.errorMessage = reason;
+    submitRecord.status = SubmitRecord.Status.RULE_ERROR;
+    return ImmutableList.of(submitRecord);
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
+      Collection<PatchSetApproval> approvals, Account.Id user) {
+    return approvals
+        .stream()
+        .filter(input -> input.getValue() < 0 || !input.getAccountId().equals(user))
+        .collect(toImmutableList());
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterApprovalsByLabel(
+      Collection<PatchSetApproval> approvals, LabelType t) {
+    return approvals
+        .stream()
+        .filter(input -> input.getLabelId().get().equals(t.getLabelId().get()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index a91d5e6..348f88c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -52,6 +53,8 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -66,28 +69,29 @@
 
 /** Creates the {@code All-Projects} repository and initial ACLs. */
 public class AllProjectsCreator {
-  private final GitRepositoryManager mgr;
+  private final GitRepositoryManager repositoryManager;
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
   private final NotesMigration notesMigration;
-  private String message;
-  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
-
-  @Nullable private GroupReference admin;
-
-  @Nullable private GroupReference batch;
   private final GroupReference anonymous;
   private final GroupReference registered;
   private final GroupReference owners;
 
+  @Nullable private GroupReference admin;
+  @Nullable private GroupReference batch;
+  private String message;
+  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
+  private LabelType codeReviewLabel;
+  private List<LabelType> additionalLabelType;
+
   @Inject
   AllProjectsCreator(
-      GitRepositoryManager mgr,
+      GitRepositoryManager repositoryManager,
       AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser,
-      NotesMigration notesMigration) {
-    this.mgr = mgr;
+      NotesMigration notesMigration,
+      SystemGroupBackend systemGroupBackend) {
+    this.repositoryManager = repositoryManager;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
     this.notesMigration = notesMigration;
@@ -95,6 +99,8 @@
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
     this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
+    this.codeReviewLabel = getDefaultCodeReviewLabel();
+    this.additionalLabelType = new ArrayList<>();
   }
 
   /** If called, grant default permissions to this admin group */
@@ -114,19 +120,35 @@
     return this;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
     checkArgument(id > 0, "id must be positive: %s", id);
     firstChangeId = id;
     return this;
   }
 
+  /** If called, the provided "Code-Review" label will be used rather than the default. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllProjectsCreator setCodeReviewLabel(LabelType labelType) {
+    checkArgument(
+        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+    this.codeReviewLabel = labelType;
+    return this;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllProjectsCreator addAdditionalLabel(LabelType labelType) {
+    additionalLabelType.add(labelType);
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allProjectsName)) {
+    try (Repository git = repositoryManager.openRepository(allProjectsName)) {
       initAllProjects(git);
     } catch (RepositoryNotFoundException notFound) {
       // A repository may be missing if this project existed only to store
       // inheritable permissions. For example 'All-Projects'.
-      try (Repository git = mgr.createRepository(allProjectsName)) {
+      try (Repository git = repositoryManager.createRepository(allProjectsName)) {
         initAllProjects(git);
         RefUpdate u = git.updateRef(Constants.HEAD);
         u.link(RefNames.REFS_CONFIG);
@@ -179,10 +201,10 @@
         stream.add(rule(config, batch));
       }
 
-      LabelType cr = initCodeReviewLabel(config);
-      grant(config, heads, cr, -1, 1, registered);
+      initLabels(config);
+      grant(config, heads, codeReviewLabel, -1, 1, registered);
 
-      grant(config, heads, cr, -2, 2, admin, owners);
+      grant(config, heads, codeReviewLabel, -2, 2, admin, owners);
       grant(config, heads, Permission.CREATE, admin, owners);
       grant(config, heads, Permission.PUSH, admin, owners);
       grant(config, heads, Permission.SUBMIT, admin, owners);
@@ -199,7 +221,7 @@
 
       meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
       grant(config, meta, Permission.READ, admin, owners);
-      grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, codeReviewLabel, -2, 2, admin, owners);
       grant(config, meta, Permission.CREATE, admin, owners);
       grant(config, meta, Permission.PUSH, admin, owners);
       grant(config, meta, Permission.SUBMIT, admin, owners);
@@ -210,7 +232,8 @@
     }
   }
 
-  public static LabelType initCodeReviewLabel(ProjectConfig c) {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static LabelType getDefaultCodeReviewLabel() {
     LabelType type =
         new LabelType(
             "Code-Review",
@@ -222,10 +245,14 @@
                 new LabelValue((short) -2, "This shall not be merged")));
     type.setCopyMinScore(true);
     type.setCopyAllScoresOnTrivialRebase(true);
-    c.getLabelSections().put(type.getName(), type);
     return type;
   }
 
+  private void initLabels(ProjectConfig projectConfig) {
+    projectConfig.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
+    additionalLabelType.forEach(t -> projectConfig.getLabelSections().put(t.getName(), t));
+  }
+
   private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
     if (notesMigration.readChangeSequence()
         && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90002f6..3779d0d 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.server.schema.AllProjectsCreator.getDefaultCodeReviewLabel;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
@@ -26,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -50,6 +53,7 @@
   private final GroupReference registered;
 
   @Nullable private GroupReference admin;
+  private LabelType codeReviewLabel;
 
   @Inject
   AllUsersCreator(
@@ -61,6 +65,7 @@
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.codeReviewLabel = getDefaultCodeReviewLabel();
   }
 
   /**
@@ -72,6 +77,15 @@
     return this;
   }
 
+  /** If called, the provided "Code-Review" label will be used rather than the default. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public AllUsersCreator setCodeReviewLabel(LabelType labelType) {
+    checkArgument(
+        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+    this.codeReviewLabel = labelType;
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
     try (Repository git = mgr.openRepository(allUsersName)) {
       initAllUsers(git);
@@ -100,11 +114,14 @@
       AccessSection users =
           config.getAccessSection(
               RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
-      LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
+
+      // Initialize "Code-Review" label.
+      config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
+
       grant(config, users, Permission.READ, false, true, registered);
       grant(config, users, Permission.PUSH, false, true, registered);
       grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, cr, -2, 2, true, registered);
+      grant(config, users, codeReviewLabel, -2, 2, true, registered);
 
       if (admin != null) {
         AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
diff --git a/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 8021a54..d4cfaa6 100644
--- a/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -44,6 +44,8 @@
 /** Provides access to the DataSource. */
 @Singleton
 public class DataSourceProvider implements Provider<DataSource>, LifecycleListener {
+  private static final String DATABASE_KEY = "database";
+
   private final Config cfg;
   private final MetricMaker metrics;
   private final Context ctx;
@@ -93,7 +95,7 @@
   }
 
   private DataSource open(Config cfg, Context context, DataSourceType dst) {
-    ConfigSection dbs = new ConfigSection(cfg, "database");
+    ConfigSection dbs = new ConfigSection(cfg, DATABASE_KEY);
     String driver = dbs.optional("driver");
     if (Strings.isNullOrEmpty(driver)) {
       driver = dst.getDriver();
@@ -112,41 +114,41 @@
     if (context == Context.SINGLE_USER) {
       usePool = false;
     } else {
-      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
+      usePool = cfg.getBoolean(DATABASE_KEY, "connectionpool", dst.usePool());
     }
 
     if (usePool) {
-      final BasicDataSource ds = new BasicDataSource();
-      ds.setDriverClassName(driver);
-      ds.setUrl(url);
+      final BasicDataSource lds = new BasicDataSource();
+      lds.setDriverClassName(driver);
+      lds.setUrl(url);
       if (username != null && !username.isEmpty()) {
-        ds.setUsername(username);
+        lds.setUsername(username);
       }
       if (password != null && !password.isEmpty()) {
-        ds.setPassword(password);
+        lds.setPassword(password);
       }
       int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
-      ds.setMaxActive(poolLimit);
-      ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
-      ds.setMaxWait(
+      lds.setMaxActive(poolLimit);
+      lds.setMinIdle(cfg.getInt(DATABASE_KEY, "poolminidle", 4));
+      lds.setMaxIdle(cfg.getInt(DATABASE_KEY, "poolmaxidle", Math.min(poolLimit, 16)));
+      lds.setMaxWait(
           ConfigUtil.getTimeUnit(
               cfg,
-              "database",
+              DATABASE_KEY,
               null,
               "poolmaxwait",
               MILLISECONDS.convert(30, SECONDS),
               MILLISECONDS));
-      ds.setInitialSize(ds.getMinIdle());
+      lds.setInitialSize(lds.getMinIdle());
       long evictIdleTimeMs = 1000L * 60;
-      ds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
-      ds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
-      ds.setTestOnBorrow(true);
-      ds.setTestOnReturn(true);
-      ds.setValidationQuery(dst.getValidationQuery());
-      ds.setValidationQueryTimeout(5);
-      exportPoolMetrics(ds);
-      return intercept(interceptor, ds);
+      lds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+      lds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
+      lds.setTestOnBorrow(true);
+      lds.setTestOnReturn(true);
+      lds.setValidationQuery(dst.getValidationQuery());
+      lds.setValidationQueryTimeout(5);
+      exportPoolMetrics(lds);
+      return intercept(interceptor, lds);
     }
     // Don't use the connection pool.
     //
diff --git a/java/com/google/gerrit/server/schema/GroupBundle.java b/java/com/google/gerrit/server/schema/GroupBundle.java
index 58d3435..a15cedc 100644
--- a/java/com/google/gerrit/server/schema/GroupBundle.java
+++ b/java/com/google/gerrit/server/schema/GroupBundle.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gerrit.server.group.InternalGroup;
@@ -118,9 +119,10 @@
       this.auditLogReader = auditLogReader;
     }
 
-    public GroupBundle fromNoteDb(Repository repo, AccountGroup.UUID uuid)
+    public GroupBundle fromNoteDb(
+        Project.NameKey projectName, Repository repo, AccountGroup.UUID uuid)
         throws ConfigInvalidException, IOException {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, uuid);
+      GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repo, uuid);
       InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
       AccountGroup.Id groupId = internalGroup.getId();
 
diff --git a/java/com/google/gerrit/server/schema/GroupRebuilder.java b/java/com/google/gerrit/server/schema/GroupRebuilder.java
index f98c948..be8dcff 100644
--- a/java/com/google/gerrit/server/schema/GroupRebuilder.java
+++ b/java/com/google/gerrit/server/schema/GroupRebuilder.java
@@ -81,7 +81,7 @@
             .setNameKey(group.getNameKey())
             .setGroupUUID(group.getGroupUUID())
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsers, allUsersRepo, groupCreation);
     groupConfig.setAllowSaveEmptyName();
 
     InternalGroupUpdate.Builder updateBuilder =
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
index d650dc7..743019d 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -214,12 +214,14 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), serverId);
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig =
+        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
     GroupNameNotes groupNameNotes =
-        GroupNameNotes.forNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+        GroupNameNotes.forNewGroup(
+            allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
     commit(allUsersRepo, groupConfig, groupNameNotes);
 
diff --git a/java/com/google/gerrit/server/schema/Schema_139.java b/java/com/google/gerrit/server/schema/Schema_139.java
index 1d90305..f362a7d 100644
--- a/java/com/google/gerrit/server/schema/Schema_139.java
+++ b/java/com/google/gerrit/server/schema/Schema_139.java
@@ -148,7 +148,7 @@
           md.getCommitBuilder().setCommitter(serverUser);
           md.setMessage(MSG);
 
-          AccountConfig accountConfig = new AccountConfig(e.getKey(), git);
+          AccountConfig accountConfig = new AccountConfig(e.getKey(), allUsersName, git);
           accountConfig.load(md);
           accountConfig.setAccountUpdate(
               InternalAccountUpdate.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_144.java b/java/com/google/gerrit/server/schema/Schema_144.java
index f1c9745..bb0cbca 100644
--- a/java/com/google/gerrit/server/schema/Schema_144.java
+++ b/java/com/google/gerrit/server/schema/Schema_144.java
@@ -80,7 +80,7 @@
 
     try {
       try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersName, repo);
         extIdNotes.upsert(toAdd);
         try (MetaDataUpdate metaDataUpdate =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
diff --git a/java/com/google/gerrit/server/schema/Schema_148.java b/java/com/google/gerrit/server/schema/Schema_148.java
index 98d4909..9433da8 100644
--- a/java/com/google/gerrit/server/schema/Schema_148.java
+++ b/java/com/google/gerrit/server/schema/Schema_148.java
@@ -55,7 +55,7 @@
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersName, repo);
       for (ExternalId extId : extIdNotes.all()) {
         if (needsUpdate(extId)) {
           extIdNotes.upsert(extId);
diff --git a/java/com/google/gerrit/server/schema/Schema_154.java b/java/com/google/gerrit/server/schema/Schema_154.java
index 8dfd356..fab1693 100644
--- a/java/com/google/gerrit/server/schema/Schema_154.java
+++ b/java/com/google/gerrit/server/schema/Schema_154.java
@@ -139,7 +139,10 @@
     PersonIdent ident = serverIdent.get();
     md.getCommitBuilder().setAuthor(ident);
     md.getCommitBuilder().setCommitter(ident);
-    new AccountConfig(account.getId(), allUsersRepo).load().setAccount(account).commit(md);
+    new AccountConfig(account.getId(), allUsersName, allUsersRepo)
+        .load()
+        .setAccount(account)
+        .commit(md);
   }
 
   @FunctionalInterface
diff --git a/java/com/google/gerrit/server/schema/Schema_160.java b/java/com/google/gerrit/server/schema/Schema_160.java
index 99ca465..eb8b70f 100644
--- a/java/com/google/gerrit/server/schema/Schema_160.java
+++ b/java/com/google/gerrit/server/schema/Schema_160.java
@@ -110,7 +110,7 @@
     md.getCommitBuilder().setAuthor(ident);
     md.getCommitBuilder().setCommitter(ident);
     Prefs prefs = new Prefs(ref);
-    prefs.load(repo);
+    prefs.load(allUsersName, repo);
     prefs.removeMyDrafts();
     prefs.commit(md);
     if (prefs.dirty()) {
diff --git a/java/com/google/gerrit/server/schema/Schema_167.java b/java/com/google/gerrit/server/schema/Schema_167.java
index ba93751..a5066cc 100644
--- a/java/com/google/gerrit/server/schema/Schema_167.java
+++ b/java/com/google/gerrit/server/schema/Schema_167.java
@@ -150,7 +150,8 @@
       ReviewDb db, Repository allUsersRepo, Config gerritConfig, SitePaths sitePaths)
       throws IOException, ConfigInvalidException {
     String serverId = new GerritServerIdProvider(gerritConfig, sitePaths).get();
-    SimpleInMemoryAccountCache accountCache = new SimpleInMemoryAccountCache(allUsersRepo);
+    SimpleInMemoryAccountCache accountCache =
+        new SimpleInMemoryAccountCache(allUsersName, allUsersRepo);
     SimpleInMemoryGroupCache groupCache = new SimpleInMemoryGroupCache(db);
     return AuditLogFormatter.create(
         accountCache::get,
@@ -178,10 +179,12 @@
   // The regular account cache isn't available during init. -> Use a simple replacement which tries
   // to load every account only once from disk.
   private static class SimpleInMemoryAccountCache {
+    private final AllUsersName allUsersName;
     private final Repository allUsersRepo;
     private Map<Account.Id, Optional<Account>> accounts = new HashMap<>();
 
-    public SimpleInMemoryAccountCache(Repository allUsersRepo) {
+    public SimpleInMemoryAccountCache(AllUsersName allUsersName, Repository allUsersRepo) {
+      this.allUsersName = allUsersName;
       this.allUsersRepo = allUsersRepo;
     }
 
@@ -192,7 +195,8 @@
 
     private Optional<Account> load(Account.Id accountId) {
       try {
-        AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+        AccountConfig accountConfig =
+            new AccountConfig(accountId, allUsersName, allUsersRepo).load();
         return accountConfig.getLoadedAccount();
       } catch (IOException | ConfigInvalidException ignored) {
         logger.atWarning().withCause(ignored).log(
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index f1749f4..08f5abb 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -28,6 +28,7 @@
   @Override
   protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
     if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && toMerge.getParentCount() > 0
         && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
       toMerge.setStatusCode(EMPTY_COMMIT);
       return;
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index fd9a6fa..1fccbdd 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.git.SubmoduleSectionParser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -51,7 +50,6 @@
 
   private static final String GIT_MODULES = ".gitmodules";
 
-  private final RequestId submissionId;
   private final Branch.NameKey branch;
   Set<SubmoduleSubscription> subscriptions;
 
@@ -62,9 +60,8 @@
       @Assisted MergeOpRepoManager orm)
       throws IOException {
     this.branch = branch;
-    this.submissionId = orm.getSubmissionId();
     Project.NameKey project = branch.getParentKey();
-    logDebug("Loading .gitmodules of %s for project %s", branch, project);
+    logger.atFine().log("Loading .gitmodules of %s for project %s", branch, project);
     try {
       OpenRepo or = orm.getRepo(project);
       ObjectId id = or.repo.resolve(branch.get());
@@ -76,7 +73,7 @@
       try (TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree())) {
         if (tw == null || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
           subscriptions = Collections.emptySet();
-          logDebug("The .gitmodules file doesn't exist in %s", branch);
+          logger.atFine().log("The .gitmodules file doesn't exist in %s", branch);
           return;
         }
       }
@@ -94,22 +91,14 @@
   }
 
   public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
-    logDebug("Checking for a subscription of %s for %s", src, branch);
+    logger.atFine().log("Checking for a subscription of %s for %s", src, branch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
-        logDebug("Found %s", s);
+        logger.atFine().log("Found %s", s);
         ret.add(s);
       }
     }
     return ret;
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(submissionId + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 9cfa272..43d5f75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -63,6 +63,8 @@
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -77,7 +79,6 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -455,78 +456,83 @@
     this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
-    submissionId = RequestId.forChange(change);
     this.db = db;
-    openRepoManager();
+    this.submissionId = new RequestId(change.getId().toString());
 
-    logDebug("Beginning integration of %s", change);
-    try {
-      ChangeSet indexBackedChangeSet =
-          mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
-      checkState(
-          indexBackedChangeSet.ids().contains(change.getId()),
-          "change %s missing from %s",
-          change.getId(),
-          indexBackedChangeSet);
-      if (indexBackedChangeSet.furtherHiddenChanges()) {
-        throw new AuthException(
-            "A change to be submitted with " + change.getId() + " is not visible");
+    try (TraceContext traceContext =
+        TraceContext.open().addTag(RequestId.Type.SUBMISSION_ID, submissionId)) {
+      openRepoManager();
+
+      logger.atFine().log("Beginning integration of %s", change);
+      try {
+        ChangeSet indexBackedChangeSet =
+            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+        checkState(
+            indexBackedChangeSet.ids().contains(change.getId()),
+            "change %s missing from %s",
+            change.getId(),
+            indexBackedChangeSet);
+        if (indexBackedChangeSet.furtherHiddenChanges()) {
+          throw new AuthException(
+              "A change to be submitted with " + change.getId() + " is not visible");
+        }
+        logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
+
+        // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
+        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+
+        // Count cross-project submissions outside of the retry loop. The chance of a single project
+        // failing increases with the number of projects, so the failure count would be inflated if
+        // this metric were incremented inside of integrateIntoHistory.
+        int projects = cs.projects().size();
+        if (projects > 1) {
+          topicMetrics.topicSubmissions.increment();
+        }
+
+        RetryTracker retryTracker = new RetryTracker();
+        retryHelper.execute(
+            updateFactory -> {
+              long attempt = retryTracker.lastAttemptNumber + 1;
+              boolean isRetry = attempt > 1;
+              if (isRetry) {
+                logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
+                this.ts = TimeUtil.nowTs();
+                openRepoManager();
+              }
+              this.commitStatus = new CommitStatus(cs, isRetry);
+              if (checkSubmitRules) {
+                logger.atFine().log("Checking submit rules and state");
+                checkSubmitRulesAndState(cs, isRetry);
+              } else {
+                logger.atFine().log("Bypassing submit rules");
+                bypassSubmitRules(cs, isRetry);
+              }
+              try {
+                integrateIntoHistory(cs);
+              } catch (IntegrationException e) {
+                logger.atSevere().withCause(e).log("Error from integrateIntoHistory");
+                throw new ResourceConflictException(e.getMessage(), e);
+              }
+              return null;
+            },
+            RetryHelper.options()
+                .listener(retryTracker)
+                // Up to the entire submit operation is retried, including possibly many projects.
+                // Multiply the timeout by the number of projects we're actually attempting to
+                // submit.
+                .timeout(
+                    retryHelper
+                        .getDefaultTimeout(ActionType.CHANGE_UPDATE)
+                        .multipliedBy(cs.projects().size()))
+                .build());
+
+        if (projects > 1) {
+          topicMetrics.topicSubmissionsCompleted.increment();
+        }
+      } catch (IOException e) {
+        // Anything before the merge attempt is an error
+        throw new OrmException(e);
       }
-      logDebug("Calculated to merge %s", indexBackedChangeSet);
-
-      // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-      ChangeSet cs = reloadChanges(indexBackedChangeSet);
-
-      // Count cross-project submissions outside of the retry loop. The chance of a single project
-      // failing increases with the number of projects, so the failure count would be inflated if
-      // this metric were incremented inside of integrateIntoHistory.
-      int projects = cs.projects().size();
-      if (projects > 1) {
-        topicMetrics.topicSubmissions.increment();
-      }
-
-      RetryTracker retryTracker = new RetryTracker();
-      retryHelper.execute(
-          updateFactory -> {
-            long attempt = retryTracker.lastAttemptNumber + 1;
-            boolean isRetry = attempt > 1;
-            if (isRetry) {
-              logDebug("Retrying, attempt #%d; skipping merged changes", attempt);
-              this.ts = TimeUtil.nowTs();
-              openRepoManager();
-            }
-            this.commitStatus = new CommitStatus(cs, isRetry);
-            if (checkSubmitRules) {
-              logDebug("Checking submit rules and state");
-              checkSubmitRulesAndState(cs, isRetry);
-            } else {
-              logDebug("Bypassing submit rules");
-              bypassSubmitRules(cs, isRetry);
-            }
-            try {
-              integrateIntoHistory(cs);
-            } catch (IntegrationException e) {
-              logError("Error from integrateIntoHistory", e);
-              throw new ResourceConflictException(e.getMessage(), e);
-            }
-            return null;
-          },
-          RetryHelper.options()
-              .listener(retryTracker)
-              // Up to the entire submit operation is retried, including possibly many projects.
-              // Multiply the timeout by the number of projects we're actually attempting to submit.
-              .timeout(
-                  retryHelper
-                      .getDefaultTimeout(ActionType.CHANGE_UPDATE)
-                      .multipliedBy(cs.projects().size()))
-              .build());
-
-      if (projects > 1) {
-        topicMetrics.topicSubmissionsCompleted.increment();
-      }
-    } catch (IOException e) {
-      // Anything before the merge attempt is an error
-      throw new OrmException(e);
     }
   }
 
@@ -535,7 +541,7 @@
       orm.close();
     }
     orm = ormProvider.get();
-    orm.setContext(db, ts, caller, submissionId);
+    orm.setContext(db, ts, caller);
   }
 
   private ChangeSet reloadChanges(ChangeSet changeSet) {
@@ -581,7 +587,7 @@
   private void integrateIntoHistory(ChangeSet cs)
       throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
-    logDebug("Beginning merge attempt on %s", cs);
+    logger.atFine().log("Beginning merge attempt on %s", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
 
     ListMultimap<Branch.NameKey, ChangeData> cbb;
@@ -609,7 +615,6 @@
       batchUpdateFactory.execute(
           orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          submissionId,
           dryrun);
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
@@ -723,7 +728,7 @@
       throw new IntegrationException("Failed to determine already accepted commits.", e);
     }
 
-    logDebug("Found %d existing heads", alreadyAccepted.size());
+    logger.atFine().log("Found %d existing heads", alreadyAccepted.size());
     return alreadyAccepted;
   }
 
@@ -737,7 +742,7 @@
 
   private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
       throws IntegrationException {
-    logDebug("Validating %d changes", submitted.size());
+    logger.atFine().log("Validating %d changes", submitted.size());
     Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
     SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
 
@@ -775,7 +780,7 @@
       }
       if (chg.currentPatchSetId() == null) {
         String msg = "Missing current patch set on change";
-        logError(msg + " " + changeId);
+        logger.atSevere().log("%s %s", msg, changeId);
         commitStatus.problem(changeId, msg);
         continue;
       }
@@ -856,7 +861,7 @@
       commit.add(or.canMergeFlag);
       toSubmit.add(commit);
     }
-    logDebug("Submitting on this run: %s", toSubmit);
+    logger.atFine().log("Submitting on this run: %s", toSubmit);
     return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
@@ -894,7 +899,7 @@
     try {
       return orm.getRepo(project);
     } catch (NoSuchProjectException e) {
-      logWarn("Project " + project + " no longer exists, abandoning open changes.");
+      logger.atWarning().log("Project %s no longer exists, abandoning open changes.", project);
       abandonAllOpenChangeForDeletedProject(project);
     } catch (IOException e) {
       throw new IntegrationException("Error opening project " + project, e);
@@ -907,7 +912,6 @@
       for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
         try (BatchUpdate bu =
             batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
-          bu.setRequestId(submissionId);
           bu.addOp(
               cd.getId(),
               new BatchUpdateOp() {
@@ -936,12 +940,14 @@
           try {
             bu.execute();
           } catch (UpdateException | RestApiException e) {
-            logWarn("Cannot abandon changes for deleted project " + destProject, e);
+            logger.atWarning().withCause(e).log(
+                "Cannot abandon changes for deleted project %s", destProject);
           }
         }
       }
     } catch (OrmException e) {
-      logWarn("Cannot abandon changes for deleted project " + destProject, e);
+      logger.atWarning().withCause(e).log(
+          "Cannot abandon changes for deleted project %s", destProject);
     }
   }
 
@@ -965,28 +971,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(submissionId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logger.atWarning().log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 67059e6..3f07ed7 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -112,7 +111,6 @@
             batchUpdateFactory
                 .create(db, getProjectName(), caller, ts)
                 .setRepository(repo, rw, ins)
-                .setRequestId(submissionId)
                 .setOnSubmitValidators(onSubmitValidatorsFactory.create());
       }
       return update;
@@ -162,7 +160,6 @@
   private ReviewDb db;
   private Timestamp ts;
   private IdentifiedUser caller;
-  private RequestId submissionId;
 
   @Inject
   MergeOpRepoManager(
@@ -178,15 +175,10 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
+  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller) {
     this.db = db;
     this.ts = ts;
     this.caller = caller;
-    this.submissionId = submissionId;
-  }
-
-  public RequestId getSubmissionId() {
-    return submissionId;
   }
 
   public OpenRepo getRepo(Project.NameKey project) throws NoSuchProjectException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 7b7ae48..6e3e0b8 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -43,13 +43,13 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2cb0744..7f70f37 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index abe3632..51dad5b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,7 +22,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -54,7 +53,6 @@
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -103,7 +101,8 @@
 
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
-    logDebug("%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
     checkState(
         ctx.getRevWalk() == args.rw,
         "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
@@ -117,18 +116,18 @@
     if (alreadyMergedCommit == null) {
       updateRepoImpl(ctx);
     } else {
-      logDebug("Already merged as %s", alreadyMergedCommit.name());
+      logger.atFine().log("Already merged as %s", alreadyMergedCommit.name());
     }
     CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
 
     if (Objects.equals(tipBefore, tipAfter)) {
-      logDebug("Did not move tip", getClass().getSimpleName());
+      logger.atFine().log("Did not move tip");
       return;
     } else if (tipAfter == null) {
-      logDebug("No merge tip, no update to perform");
+      logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logDebug("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -144,7 +143,7 @@
       throws IntegrationException {
     String refName = getDest().get();
     if (RefNames.REFS_CONFIG.equals(refName)) {
-      logDebug("Loading new configuration from %s", RefNames.REFS_CONFIG);
+      logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
         ProjectConfig cfg = new ProjectConfig(getProject());
         cfg.load(ctx.getRevWalk(), commit);
@@ -184,8 +183,7 @@
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
-    Collections.sort(
-        commits,
+    commits.sort(
         ReviewDbUtil.intKeyOrdering().reverse().onResultOf(CodeReviewCommit::getPatchsetId));
     CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
     if (result == null) {
@@ -216,7 +214,8 @@
 
   @Override
   public final boolean updateChange(ChangeContext ctx) throws Exception {
-    logDebug("%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
     toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
     PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
     PatchSet.Id newPsId;
@@ -225,12 +224,12 @@
       // Either another thread won a race, or we are retrying a whole topic submission after one
       // repo failed with lock failure.
       if (alreadyMergedCommit == null) {
-        logDebug(
+        logger.atFine().log(
             "Change is already merged according to its status, but we were unable to find it"
                 + " merged into the current tip (%s)",
             args.mergeTip.getCurrentTip().name());
       } else {
-        logDebug("Change is already merged");
+        logger.atFine().log("Change is already merged");
       }
       changeAlreadyMerged = true;
       return false;
@@ -276,7 +275,7 @@
     checkNotNull(commit, "missing commit for change " + id);
     CommitMergeStatus s = commit.getStatusCode();
     checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
-    logDebug("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
+    logger.atFine().log("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
     setApproval(ctx, args.caller);
 
     mergeResultRev =
@@ -302,7 +301,7 @@
   private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
       throws IOException, OrmException {
     PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
-    logDebug("Fixing up already-merged patch set %s", psId);
+    logger.atFine().log("Fixing up already-merged patch set %s", psId);
     PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
     ctx.getRevWalk().parseBody(alreadyMergedCommit);
     ctx.getChange()
@@ -310,7 +309,7 @@
             psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
     PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     if (existing != null) {
-      logDebug("Patch set row exists, only updating change");
+      logger.atFine().log("Patch set row exists, only updating change");
       return existing;
     }
     // No patch set for the already merged commit, although we know it came form
@@ -336,7 +335,7 @@
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
     PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
 
-    logDebug("Add approval for %s", id);
+    logger.atFine().log("Add approval for %s", id);
     ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
     origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
     LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
@@ -397,7 +396,7 @@
     // change happened.
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
-        logDebug("Adding submit label %s", psa);
+        logger.atFine().log("Adding submit label %s", psa);
         update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
       }
     }
@@ -488,7 +487,7 @@
   private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
     Change c = ctx.getChange();
     ReviewDb db = ctx.getDb();
-    logDebug("Setting change %s merged", c.getId());
+    logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toStringForStorage());
 
@@ -511,7 +510,7 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logDebug("Skipping post-update steps for change %s", getId());
+      logger.atFine().log("Skipping post-update steps for change %s", getId());
       return;
     }
     postUpdateImpl(ctx);
@@ -595,37 +594,4 @@
           "cannot update gitlink for the commit at branch: " + args.destBranch);
     }
   }
-
-  protected final void logDebug(String msg) {
-    logger.atFine().log(this.args.submissionId + msg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(this.args.submissionId + msg, arg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2);
-  }
-
-  protected final void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  protected final void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 319e2e1..50be62a 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.submit;
 
 import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -140,17 +141,31 @@
   private final MergeOpRepoManager orm;
   private final Map<Branch.NameKey, GitModules> branchGitModules;
 
-  // always update-to-current branch tips during submit process
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<Branch.NameKey> updatedBranches;
+
+  /**
+   * Current branch tips, taking into account commits created during the submit process as well as
+   * submodule updates produced by this class.
+   */
   private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
-  // branches for all the submitting changes
-  private final Set<Branch.NameKey> updatedBranches;
-  // branches which in either a submodule or a superproject
+
+  /**
+   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
+   * which are subscribed to by some superproject.
+   */
   private final Set<Branch.NameKey> affectedBranches;
-  // sorted version of affectedBranches
+
+  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
   private final ImmutableSet<Branch.NameKey> sortedBranches;
-  // map of superproject branch and its submodule subscriptions
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
   private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
-  // map of superproject and its branches which has submodule subscriptions
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
   private SubmoduleOp(
@@ -174,29 +189,57 @@
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = updatedBranches;
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
     this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
     this.affectedBranches = new HashSet<>();
     this.branchTips = new HashMap<>();
     this.branchGitModules = new HashMap<>();
     this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMap();
+    this.sortedBranches = calculateSubscriptionMaps();
   }
 
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap() throws SubmoduleException {
+  /**
+   * Calculate the internal maps used by the operation.
+   *
+   * <p>In addition to the return value, the following fields are populated as a side effect:
+   *
+   * <ul>
+   *   <li>{@link #affectedBranches}
+   *   <li>{@link #targets}
+   *   <li>{@link #branchesByProject}
+   * </ul>
+   *
+   * @return the ordered set to be stored in {@link #sortedBranches}.
+   * @throws SubmoduleException if an error occurred walking projects.
+   */
+  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
+  // mutable maps, which makes this whole class difficult to understand.
+  //
+  // A cleaner architecture for this process might be:
+  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
+  //      structure representing the subscription graph, using a separate class with a properly-
+  //      documented interface.
+  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
+  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
+  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
+  //      relevant updates.
+  //
+  // In addition to improving readability, this approach has the advantage of making (1) and (2)
+  // testable using small tests.
+  private ImmutableSet<Branch.NameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
-      logDebug("Updating superprojects disabled");
+      logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
-    logDebug("Calculating superprojects - submodules map");
+    logger.atFine().log("Calculating superprojects - submodules map");
     LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
     for (Branch.NameKey updatedBranch : updatedBranches) {
       if (allVisited.contains(updatedBranch)) {
         continue;
       }
 
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(), allVisited);
+      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
     }
 
     // Since the searchForSuperprojects will add all branches (related or
@@ -213,7 +256,7 @@
       LinkedHashSet<Branch.NameKey> currentVisited,
       LinkedHashSet<Branch.NameKey> allVisited)
       throws SubmoduleException {
-    logDebug("Now processing %s", current);
+    logger.atFine().log("Now processing %s", current);
 
     if (currentVisited.contains(current)) {
       throw new SubmoduleException(
@@ -275,9 +318,9 @@
   private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
       throws IOException {
     Collection<Branch.NameKey> ret = new HashSet<>();
-    logDebug("Inspecting SubscribeSection %s", s);
+    logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
-      logDebug("Inspecting [matching] ref %s", r);
+      logger.atFine().log("Inspecting [matching] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -295,7 +338,7 @@
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logDebug("Inspecting [all] ref %s", r);
+      logger.atFine().log("Inspecting [all] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -319,17 +362,18 @@
         }
       }
     }
-    logDebug("Returning possible branches: %s for project %s", ret, s.getProject());
+    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
     return ret;
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
       Branch.NameKey srcBranch) throws IOException {
-    logDebug("Calculating possible superprojects for %s", srcBranch);
+    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey srcProject = srcBranch.getParentKey();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
-      logDebug("Checking subscribe section %s", s);
+      logger.atFine().log("Checking subscribe section %s", s);
       Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
       for (Branch.NameKey targetBranch : branches) {
         Project.NameKey targetProject = targetBranch.getParentKey();
@@ -337,11 +381,11 @@
           OpenRepo or = orm.getRepo(targetProject);
           ObjectId id = or.repo.resolve(targetBranch.get());
           if (id == null) {
-            logDebug("The branch %s doesn't exist.", targetBranch);
+            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
             continue;
           }
         } catch (NoSuchProjectException e) {
-          logDebug("The project %s doesn't exist", targetProject);
+          logger.atFine().log("The project %s doesn't exist", targetProject);
           continue;
         }
 
@@ -353,7 +397,7 @@
         ret.addAll(m.subscribedTo(srcBranch));
       }
     }
-    logDebug("Calculated superprojects for %s are %s", srcBranch, ret);
+    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
     return ret;
   }
 
@@ -376,15 +420,14 @@
           }
         }
       }
-      batchUpdateFactory.execute(
-          orm.batchUpdates(superProjects), BatchUpdateListener.NONE, orm.getSubmissionId(), false);
+      batchUpdateFactory.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
     } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
   /** Create a separate gitlink commit */
-  public CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
+  private CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -406,14 +449,18 @@
       addBranchTip(subscriber, currentCommit);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     int count = 0;
 
-    List<SubmoduleSubscription> subscriptions = new ArrayList<>(targets.get(subscriber));
-    Collections.sort(subscriptions, comparing(SubmoduleSubscription::getPath));
+    List<SubmoduleSubscription> subscriptions =
+        targets
+            .get(subscriber)
+            .stream()
+            .sorted(comparing(SubmoduleSubscription::getPath))
+            .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
       if (count > 0) {
         msgbuf.append("\n\n");
@@ -452,8 +499,7 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  public CodeReviewCommit composeGitlinksCommit(
-      Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -462,7 +508,7 @@
       throw new SubmoduleException("Cannot access superproject", e);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     for (SubmoduleSubscription s : targets.get(subscriber)) {
@@ -496,6 +542,7 @@
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleException, IOException {
+    logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
       subOr = orm.getRepo(s.getSubmodule().getParentKey());
@@ -523,7 +570,14 @@
       // check that the old gitlink is a commit that actually exists. If not, then there is an
       // inconsistency between the superproject and subproject state, and we don't want to risk
       // making things worse by updating the gitlink to something else.
-      oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      try {
+        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
     }
 
     final CodeReviewCommit newCommit;
@@ -577,7 +631,8 @@
     msgbuf.append(" from branch '");
     msgbuf.append(s.getSubmodule().getShortName());
     msgbuf.append("'");
-    msgbuf.append("\n  to " + newCommit.getName());
+    msgbuf.append("\n  to ");
+    msgbuf.append(newCommit.getName());
 
     // newly created submodule gitlink, do not append whole history
     if (oldCommit == null) {
@@ -628,7 +683,7 @@
     return dc;
   }
 
-  public ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
     for (Project.NameKey project : branchesByProject.keySet()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
@@ -671,7 +726,7 @@
     projects.add(project);
   }
 
-  public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
+  ImmutableSet<Branch.NameKey> getBranchesInOrder() {
     LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
@@ -680,27 +735,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  public boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(Branch.NameKey branch) {
     return targets.containsKey(branch);
   }
 
-  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  public void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, Branch.NameKey branch) {
     bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/TestHelperOp.java b/java/com/google/gerrit/server/submit/TestHelperOp.java
index 2f0a3f6..bbb198a 100644
--- a/java/com/google/gerrit/server/submit/TestHelperOp.java
+++ b/java/com/google/gerrit/server/submit/TestHelperOp.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestId;
 import java.io.IOException;
 import java.util.Queue;
 import org.eclipse.jgit.lib.ObjectId;
@@ -30,27 +28,22 @@
 
   private final Change.Id changeId;
   private final TestSubmitInput input;
-  private final RequestId submissionId;
 
   TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
     this.changeId = changeId;
     this.input = (TestSubmitInput) args.submitInput;
-    this.submissionId = args.submissionId;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
     Queue<Boolean> q = input.generateLockFailures;
     if (q != null && !q.isEmpty() && q.remove()) {
-      logDebug("Adding bogus ref update to trigger lock failure, via change %s", changeId);
+      logger.atFine().log(
+          "Adding bogus ref update to trigger lock failure, via change %s", changeId);
       ctx.addRefUpdate(
           ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
           ObjectId.zeroId(),
           "refs/test/" + getClass().getSimpleName());
     }
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index dd3cc73..60cacee 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -38,12 +38,12 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -126,10 +126,7 @@
 
     @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
-        Collection<BatchUpdate> updates,
-        BatchUpdateListener listener,
-        @Nullable RequestId requestId,
-        boolean dryRun)
+        Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryRun)
         throws UpdateException, RestApiException {
       checkNotNull(listener);
       checkDifferentProject(updates);
@@ -141,11 +138,11 @@
       if (migration.disableChangeReviewDb()) {
         ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        NoteDbBatchUpdate.execute(noteDbUpdates, listener, dryRun);
       } else {
         ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, dryRun);
       }
     }
 
@@ -159,20 +156,6 @@
     }
   }
 
-  static void setRequestIds(
-      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
-  }
-
   static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
@@ -248,7 +231,6 @@
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
-  protected RequestId requestId;
   protected PushCertificate pushCert;
   protected String refLogMessage;
 
@@ -284,11 +266,6 @@
 
   protected abstract Context newContext();
 
-  public BatchUpdate setRequestId(RequestId requestId) {
-    this.requestId = requestId;
-    return this;
-  }
-
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
     checkState(this.repoView == null, "repo already set");
     repoView = new RepoView(repo, revWalk, inserter);
@@ -384,39 +361,39 @@
     return this;
   }
 
-  protected void logDebug(String msg, Throwable t) {
+  protected static void logDebug(String msg, Throwable t) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().withCause(t).log(requestId + "%s", msg);
+    if (RequestId.isSet()) {
+      logger.atFine().withCause(t).log("%s", msg);
     }
   }
 
-  protected void logDebug(String msg) {
+  protected static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg) {
+  protected static void logDebug(String msg, @Nullable Object arg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
+  protected static void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg1, arg2);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg1, arg2);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
index 8612fac..abe865c 100644
--- a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -35,7 +34,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -68,15 +66,11 @@
   }
 
   static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<NoteDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
 
     try {
       @SuppressWarnings("deprecation")
@@ -293,7 +287,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 132c04b..10e3455 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -31,6 +31,7 @@
 import com.google.common.base.Predicate;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Maps;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Counter1;
@@ -52,6 +53,8 @@
 
 @Singleton
 public class RetryHelper {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @FunctionalInterface
   public interface ChangeAction<T> {
     T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
@@ -277,6 +280,9 @@
       retryerBuilder.withRetryListener(listener);
       return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
     } finally {
+      if (listener.getAttemptCount() > 1) {
+        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+      }
       metrics.attemptCounts.record(actionType, listener.getAttemptCount());
     }
   }
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 9bf4bb2..df50bd5 100644
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -28,7 +28,6 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
@@ -51,6 +50,7 @@
 import com.google.gerrit.server.git.InsertedObject;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
@@ -58,7 +58,6 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -240,15 +239,11 @@
   }
 
   static void execute(
-      ImmutableList<ReviewDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<ReviewDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
     try {
       Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
@@ -358,7 +353,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
@@ -616,7 +611,6 @@
     NoteDbUpdateManager.StagedResult noteDbResult;
     boolean dirty;
     boolean deleted;
-    private String taskId;
 
     private ChangeTask(
         Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
@@ -628,27 +622,30 @@
 
     @Override
     public Void call() throws Exception {
-      taskId = id.toString() + "-" + Thread.currentThread().getId();
-      if (Thread.currentThread() == mainThread) {
-        initRepository();
-        Repository repo = repoView.getRepository();
-        try (RevWalk rw = new RevWalk(repo)) {
-          call(ReviewDbBatchUpdate.this.db, repo, rw);
+      try (TraceContext traceContext =
+          TraceContext.open()
+              .addTag("TASK_ID", id.toString() + "-" + Thread.currentThread().getId())) {
+        if (Thread.currentThread() == mainThread) {
+          initRepository();
+          Repository repo = repoView.getRepository();
+          try (RevWalk rw = new RevWalk(repo)) {
+            call(ReviewDbBatchUpdate.this.db, repo, rw);
+          }
+        } else {
+          // Possible optimization: allow Ops to declare whether they need to
+          // access the repo from updateChange, and don't open in this thread
+          // unless we need it. However, as of this writing the only operations
+          // that are executed in parallel are during ReceiveCommits, and they
+          // all need the repo open anyway. (The non-parallel case above does not
+          // reopen the repo.)
+          try (ReviewDb threadLocalDb = schemaFactory.open();
+              Repository repo = repoManager.openRepository(project);
+              RevWalk rw = new RevWalk(repo)) {
+            call(threadLocalDb, repo, rw);
+          }
         }
-      } else {
-        // Possible optimization: allow Ops to declare whether they need to
-        // access the repo from updateChange, and don't open in this thread
-        // unless we need it. However, as of this writing the only operations
-        // that are executed in parallel are during ReceiveCommits, and they
-        // all need the repo open anyway. (The non-parallel case above does not
-        // reopen the repo.)
-        try (ReviewDb threadLocalDb = schemaFactory.open();
-            Repository repo = repoManager.openRepository(project);
-            RevWalk rw = new RevWalk(repo)) {
-          call(threadLocalDb, repo, rw);
-        }
+        return null;
       }
-      return null;
     }
 
     private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
@@ -822,18 +819,6 @@
     private boolean isNewChange(Change.Id id) {
       return newChanges.containsKey(id);
     }
-
-    private void logDebug(String msg, Throwable t) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, t);
-    }
-
-    private void logDebug(String msg) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg);
-    }
-
-    private void logDebug(String msg, @Nullable Object arg) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, arg);
-    }
   }
 
   private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 4743b35..47b1d89 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 3eef4d6..1fdf7d8 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -77,11 +78,12 @@
     int threads = cfg.getInt("sshd", "commandStartThreads", 2);
     startExecutor = workQueue.createQueue(threads, "SshCommandStart", true);
     destroyExecutor =
-        Executors.newSingleThreadExecutor(
-            new ThreadFactoryBuilder()
-                .setNameFormat("SshCommandDestroy-%s")
-                .setDaemon(true)
-                .build());
+        new LoggingContextAwareExecutorService(
+            Executors.newSingleThreadExecutor(
+                new ThreadFactoryBuilder()
+                    .setNameFormat("SshCommandDestroy-%s")
+                    .setDaemon(true)
+                    .build()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 3e42ebe..99c8724 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,11 +14,19 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.server.logging.TraceContext;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Option(name = "--trace", usage = "enable request tracing")
+  private boolean trace;
+
+  @Option(name = "--trace-id", usage = "trace ID (can only be set if --trace was set too)")
+  private String traceId;
+
   protected PrintWriter stdout;
   protected PrintWriter stderr;
 
@@ -31,7 +39,7 @@
             parseCommandLine();
             stdout = toPrintWriter(out);
             stderr = toPrintWriter(err);
-            try {
+            try (TraceContext traceContext = enableTracing()) {
               SshCommand.this.run();
             } finally {
               stdout.flush();
@@ -42,4 +50,14 @@
   }
 
   protected abstract void run() throws UnloggedFailure, Failure, Exception;
+
+  private TraceContext enableTracing() throws UnloggedFailure {
+    if (!trace && traceId != null) {
+      throw die("A trace ID can only be set if --trace was specified.");
+    }
+    return TraceContext.newTrace(
+        trace,
+        traceId,
+        (tagName, traceId) -> stderr.println(String.format("%s: %s", tagName, traceId)));
+  }
 }
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index b573062..81ce91d 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -86,6 +86,7 @@
   @Override
   public void evict(String username) {
     if (username != null) {
+      logger.atFine().log("Evict SSH key for username %s", username);
       cache.invalidate(username);
     }
   }
@@ -102,6 +103,8 @@
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
+      logger.atFine().log("Loading SSH keys for account with username %s", username);
+
       Optional<ExternalId> user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
       if (!user.isPresent()) {
         return NO_SUCH_USER;
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 0e36d53..1c857e4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -114,7 +114,7 @@
     command(gerrit, SetMembersCommand.class);
     command(gerrit, CreateBranchCommand.class);
     command(gerrit, SetAccountCommand.class);
-    command(gerrit, AdminSetParent.class);
+    command(gerrit, SetParentCommand.class);
 
     command(testSubmit, TestSubmitRuleCommand.class);
     command(testSubmit, TestSubmitTypeCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
similarity index 66%
rename from java/com/google/gerrit/sshd/commands/AdminSetParent.java
rename to java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 67ed098..56ee371 100644
--- a/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -16,41 +16,36 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.server.restapi.project.SetParent;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(
     name = "set-project-parent",
     description = "Change the project permissions are inherited from")
-final class AdminSetParent extends SshCommand {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+final class SetParentCommand extends SshCommand {
   @Option(
       name = "--parent",
       aliases = {"-p"},
@@ -80,14 +75,18 @@
 
   @Inject private ProjectCache projectCache;
 
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private AllProjectsName allProjectsName;
-
   @Inject private ListChildProjects listChildProjects;
 
+  @Inject private SetParent setParent;
+
   private Project.NameKey newParentKey;
 
+  private static ParentInput parentInput(String parent) {
+    ParentInput input = new ParentInput();
+    input.parent = parent;
+    return input;
+  }
+
   @Override
   protected void run() throws Failure {
     if (oldParent == null && children.isEmpty()) {
@@ -100,25 +99,9 @@
     }
 
     final StringBuilder err = new StringBuilder();
-    final Set<Project.NameKey> grandParents = new HashSet<>();
-
-    grandParents.add(allProjectsName);
 
     if (newParent != null) {
       newParentKey = newParent.getProject().getNameKey();
-
-      // Catalog all grandparents of the "parent", we want to
-      // catch a cycle in the parent pointers before it occurs.
-      //
-      Project.NameKey gp = newParent.getProject().getParent();
-      while (gp != null && grandParents.add(gp)) {
-        final ProjectState s = projectCache.get(gp);
-        if (s != null) {
-          gp = s.getProject().getParent();
-        } else {
-          break;
-        }
-      }
     }
 
     final List<Project.NameKey> childProjects =
@@ -135,47 +118,19 @@
 
     for (Project.NameKey nameKey : childProjects) {
       final String name = nameKey.get();
-
-      if (allProjectsName.equals(nameKey)) {
-        // Don't allow the wild card project to have a parent.
-        //
-        err.append("error: Cannot set parent of '").append(name).append("'\n");
-        continue;
-      }
-
-      if (grandParents.contains(nameKey) || nameKey.equals(newParentKey)) {
-        // Try to avoid creating a cycle in the parent pointers.
-        //
-        err.append("error: Cycle exists between '")
-            .append(name)
-            .append("' and '")
-            .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
-            .append("'\n");
-        continue;
-      }
-
-      try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-        ProjectConfig config = ProjectConfig.read(md);
-        config.getProject().setParentName(newParentKey);
-        md.setMessage(
-            "Inherit access from "
-                + (newParentKey != null ? newParentKey.get() : allProjectsName.get())
-                + "\n");
-        config.commit(md);
-      } catch (RepositoryNotFoundException notFound) {
-        err.append("error: Project ").append(name).append(" not found\n");
-      } catch (IOException | ConfigInvalidException e) {
-        final String msg = "Cannot update project " + name;
-        logger.atSevere().withCause(e).log(msg);
-        err.append("error: ").append(msg).append("\n");
-      }
-
+      ProjectState project = projectCache.get(nameKey);
       try {
-        projectCache.evict(nameKey);
-      } catch (IOException e) {
-        final String msg = "Cannot reindex project: " + name;
-        logger.atSevere().withCause(e).log(msg);
-        err.append("error: ").append(msg).append("\n");
+        setParent.apply(new ProjectResource(project, user), parentInput(newParentKey.get()));
+      } catch (AuthException e) {
+        err.append("error: insuffient access rights to change parent of '")
+            .append(name)
+            .append("'\n");
+      } catch (ResourceConflictException | ResourceNotFoundException | BadRequestException e) {
+        err.append("error: ").append(e.getMessage()).append("'\n");
+      } catch (UnprocessableEntityException | IOException e) {
+        throw new Failure(1, "failure in request", e);
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
       }
     }
 
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 9b517c6..baadf02 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.sshd.commands;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -33,11 +35,7 @@
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.Date;
-import java.util.List;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -92,25 +90,27 @@
       throw new Failure(1, "fatal: sshd no longer running");
     }
 
-    final List<IoSession> list = new ArrayList<>(acceptor.getManagedSessions().values());
-    Collections.sort(
-        list,
-        new Comparator<IoSession>() {
-          @Override
-          public int compare(IoSession arg0, IoSession arg1) {
-            if (arg0 instanceof MinaSession) {
-              MinaSession mArg0 = (MinaSession) arg0;
-              MinaSession mArg1 = (MinaSession) arg1;
-              if (mArg0.getSession().getCreationTime() < mArg1.getSession().getCreationTime()) {
-                return -1;
-              } else if (mArg0.getSession().getCreationTime()
-                  > mArg1.getSession().getCreationTime()) {
-                return 1;
-              }
-            }
-            return (int) (arg0.getId() - arg1.getId());
-          }
-        });
+    final ImmutableList<IoSession> list =
+        acceptor
+            .getManagedSessions()
+            .values()
+            .stream()
+            .sorted(
+                (arg0, arg1) -> {
+                  if (arg0 instanceof MinaSession) {
+                    MinaSession mArg0 = (MinaSession) arg0;
+                    MinaSession mArg1 = (MinaSession) arg1;
+                    if (mArg0.getSession().getCreationTime()
+                        < mArg1.getSession().getCreationTime()) {
+                      return -1;
+                    } else if (mArg0.getSession().getCreationTime()
+                        > mArg1.getSession().getCreationTime()) {
+                      return 1;
+                    }
+                  }
+                  return (int) (arg0.getId() - arg1.getId());
+                })
+            .collect(toImmutableList());
 
     hostNameWidth = wide ? Integer.MAX_VALUE : columns - 9 - 9 - 10 - 32;
 
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index c97372c..ffd98d5 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -151,6 +151,7 @@
     stdout = toPrintWriter(out);
     eventListenerRegistration =
         eventListeners.add(
+            "gerrit",
             new UserScopedEventListener() {
               @Override
               public void onEvent(Event event) {
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index cf65908..15ceb77 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -29,6 +29,7 @@
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//lib:guava",
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index b0229c3..9e45b7c 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gerrit.server.logging.LoggingContext;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -105,11 +106,13 @@
  */
 public class ConfigSuite extends Suite {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   static {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+    System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 
   public static final String DEFAULT = "default";
@@ -156,7 +159,7 @@
 
     @Override
     public Object createTest() throws Exception {
-      Object test = getTestClass().getJavaClass().newInstance();
+      Object test = getTestClass().getJavaClass().getDeclaredConstructor().newInstance();
       parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
       if (nameField != null) {
         nameField.set(test, name);
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index 91c14f7..c94fc1d 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//lib:args4j",
         "//lib:guava",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 8639a06..231b335 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -40,6 +40,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.StringWriter;
@@ -77,6 +78,8 @@
  * from the GNU style format to the args4j style format prior to invoking args4j for parsing.
  */
 public class CmdLineParser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     CmdLineParser create(Object bean);
   }
@@ -235,6 +238,7 @@
   }
 
   public void parseOptionMap(ListMultimap<String, String> params) throws CmdLineException {
+    logger.atFinest().log("Command-line parameters: %s", params.keySet());
     List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
     for (String key : params.keySet()) {
       String name = makeOption(key);
@@ -326,8 +330,8 @@
   }
 
   private static class PrefixedOption implements Option {
-    String prefix;
-    Option o;
+    private final String prefix;
+    private final Option o;
 
     PrefixedOption(String prefix, Option o) {
       this.prefix = prefix;
diff --git a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
index 1318125..20d093e 100644
--- a/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
@@ -14,6 +14,9 @@
 
 package com.google.gwtexpui.globalkey.client;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
@@ -31,10 +34,7 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -228,15 +228,6 @@
   }
 
   private List<KeyCommand> sort(KeyCommandSet set) {
-    final List<KeyCommand> keys = new ArrayList<>(set.getKeys());
-    Collections.sort(
-        keys,
-        new Comparator<KeyCommand>() {
-          @Override
-          public int compare(KeyCommand arg0, KeyCommand arg1) {
-            return arg0.getHelpText().compareTo(arg1.getHelpText());
-          }
-        });
-    return keys;
+    return set.getKeys().stream().sorted(comparing(KeyCommand::getHelpText)).collect(toList());
   }
 }
diff --git a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index ef80cdb..758521f 100644
--- a/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -14,11 +14,12 @@
 
 package com.google.gwtexpui.safehtml.client;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gwt.user.client.ui.SuggestOracle;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 
 /**
@@ -115,15 +116,8 @@
      * terms.
      */
     private static List<String> splitQuery(String query) {
-      List<String> queryTerms = Arrays.asList(query.split("\\s+"));
-      Collections.sort(
-          queryTerms,
-          new Comparator<String>() {
-            @Override
-            public int compare(String s1, String s2) {
-              return Integer.compare(s2.length(), s1.length());
-            }
-          });
+      List<String> queryTerms =
+          Arrays.stream(query.split("\\s+")).sorted(comparing(String::length)).collect(toList());
 
       List<String> result = new ArrayList<>();
       for (String s : queryTerms) {
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index 3c7b966..bf387fd 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -39,7 +39,7 @@
 
   @Test
   public void universalGroupBackendHandlesTestGroup() throws Exception {
-    RegistrationHandle registrationHandle = groupBackends.add(testGroupBackend);
+    RegistrationHandle registrationHandle = groupBackends.add("gerrit", testGroupBackend);
     try {
       assertThat(universalGroupBackend.handles(testUUID)).isTrue();
     } finally {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index c619e8c..de66b87 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -230,7 +230,7 @@
   @Before
   public void addAccountIndexEventCounter() {
     accountIndexedCounter = new AccountIndexedCounter();
-    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+    accountIndexEventCounterHandle = accountIndexedListeners.add("gerrit", accountIndexedCounter);
   }
 
   @After
@@ -243,7 +243,7 @@
   @Before
   public void addRefUpdateCounter() {
     refUpdateCounter = new RefUpdateCounter();
-    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+    refUpdateCounterHandle = refUpdateListeners.add("gerrit", refUpdateCounter);
   }
 
   @After
@@ -526,12 +526,13 @@
 
   @Test
   public void validateAccountActivation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount activatableAccount =
+    Account.Id activatableAccountId =
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-    com.google.gerrit.acceptance.testsuite.account.TestAccount deactivatableAccount =
+    Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
     RegistrationHandle registrationHandle =
         accountActivationValidationListeners.add(
+            "gerrit",
             new AccountActivationValidationListener() {
               @Override
               public void validateActivation(AccountState account) throws ValidationException {
@@ -553,61 +554,56 @@
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
 
       // Activate account that can be activated
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Activate account that is already active
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Try deactivating account that cannot be deactivated
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to deactive account");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isTrue();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
 
       // Deactivate account that can be deactivated
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Try activating account that cannot be activated
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to active account");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
     } finally {
       registrationHandle.remove();
     }
@@ -2168,6 +2164,38 @@
   }
 
   @Test
+  public void createUserWithValidUsername() throws Exception {
+    ImmutableList<String> names =
+        ImmutableList.of(
+            "user@domain",
+            "user-name",
+            "user_name",
+            "1234",
+            "user1234",
+            "1234@domain",
+            "user!+alias{*}#$%&’^=~|@domain");
+    for (String name : names) {
+      gApi.accounts().create(name);
+    }
+  }
+
+  @Test
+  public void createUserWithInvalidUsername() throws Exception {
+    ImmutableList<String> invalidNames =
+        ImmutableList.of(
+            "@", "@foo", "-", "-foo", "_", "_foo", "!", "+", "{", "}", "*", "%", "#", "$", "&", "’",
+            "^", "=", "~");
+    for (String name : invalidNames) {
+      try {
+        gApi.accounts().create(name);
+        fail(String.format("Expected BadRequestException for username [%s]", name));
+      } catch (BadRequestException e) {
+        assertThat(e).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
+      }
+    }
+  }
+
+  @Test
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
     accountOperations.newAccount().username(username).create();
@@ -2508,7 +2536,7 @@
     // Manually inserting/updating/deleting an external ID of the user makes the index document
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
 
       ExternalId.Key key = ExternalId.Key.create("foo", "foo");
       extIdNotes.insert(ExternalId.create(key, accountId));
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index ef8451d..60a61d1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -162,7 +162,7 @@
       md.getCommitBuilder().setAuthor(ident);
       md.getCommitBuilder().setCommitter(ident);
 
-      AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+      AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
       accountConfig.setAccountUpdate(accountUpdate);
       accountConfig.commit(md);
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 0a42b1e..b3a5e2d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -68,7 +68,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
-import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelFunction;
@@ -203,7 +202,7 @@
   @Before
   public void addChangeIndexedCounter() {
     changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
   }
 
   @After
@@ -214,16 +213,6 @@
   }
 
   @Test
-  public void reflog() throws Exception {
-    // Tests are using DfsRepository which does not implement getReflogReader,
-    // so this will always fail.
-    // TODO: change this if/when DfsRepository#getReflogReader is implemented.
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("reflog not supported");
-    gApi.projects().name(project.get()).branch("master").reflog();
-  }
-
-  @Test
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -1698,7 +1687,7 @@
     // create a group named "ab" with one user: testUser
     String email = "abcd@test.com";
     String fullname = "abcd";
-    TestAccount testUser =
+    Account.Id accountIdOfTestUser =
         accountOperations
             .newAccount()
             .username("abcd")
@@ -1731,7 +1720,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1758,7 +1747,7 @@
 
     String myGroupUserEmail = "lee@test.com";
     String myGroupUserFullname = "lee";
-    TestAccount myGroupUser =
+    Account.Id accountIdOfGroupUser =
         accountOperations
             .newAccount()
             .username("lee")
@@ -1795,7 +1784,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -2225,13 +2214,12 @@
 
     // notify unrelated account as TO
     String email = "user2@example.com";
-    TestAccount user2 =
-        accountOperations
-            .newAccount()
-            .username("user2")
-            .preferredEmail(email)
-            .fullname("User2")
-            .create();
+    accountOperations
+        .newAccount()
+        .username("user2")
+        .preferredEmail(email)
+        .fullname("User2")
+        .create();
     setApiUser(user);
     recommend(r.getChangeId());
     setApiUser(admin);
@@ -2239,7 +2227,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyTo(user2);
+    assertNotifyTo(email, "User2");
 
     // notify unrelated account as CC
     setApiUser(user);
@@ -2249,7 +2237,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyCc(user2);
+    assertNotifyCc(email, "User2");
 
     // notify unrelated account as BCC
     setApiUser(user);
@@ -2259,7 +2247,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyBcc(user2);
+    assertNotifyBcc(email, "User2");
   }
 
   @Test
@@ -2609,6 +2597,7 @@
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 4e3f048..d6be960 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -183,24 +183,23 @@
   @Test
   public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
     String username = name("user");
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().username(username).create();
+    Account.Id accountId = accountOperations.newAccount().username(username).create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
     String groupName = createGroup("users");
     AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
 
     gApi.groups().id(groupName).addMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
 
     gApi.groups().id(groupName).removeMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
   }
 
@@ -411,19 +410,17 @@
 
   @Test
   public void cachedGroupsForMemberAreUpdatedOnGroupCreation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().create();
+    Account.Id accountId = accountOperations.newAccount().create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("Users");
-    groupInput.members = ImmutableList.of(String.valueOf(account.accountId().get()));
+    groupInput.members = ImmutableList.of(String.valueOf(accountId.get()));
     GroupInfo group = gApi.groups().create(groupInput).get();
 
-    Collection<AccountGroup.UUID> groups =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
   }
 
@@ -1298,7 +1295,7 @@
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
     RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add(groupIndexedCounter);
+        groupIndexedListeners.add("gerrit", groupIndexedCounter);
     try {
       // Running the reindexer right after startup should not need to reindex any group since
       // reindexing was already done on startup.
@@ -1355,7 +1352,7 @@
 
     GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
     RegistrationHandle groupIndexEventCounterHandle =
-        groupIndexedListeners.add(groupIndexedCounter);
+        groupIndexedListeners.add("gerrit", groupIndexedCounter);
     try {
       // No group indexing happened on startup. All groups should be reindexed now.
       slaveGroupIndexer.run();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 2b1416a..e4194a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -20,16 +20,18 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -39,35 +41,29 @@
 
 public class CheckAccessIT extends AbstractDaemonTest {
 
+  @Inject private GroupOperations groupOperations;
+
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
   private Project.NameKey secretRefProject;
   private TestAccount privilegedUser;
-  private InternalGroup privilegedGroup;
 
   @Before
   public void setUp() throws Exception {
     normalProject = createProject("normal");
     secretProject = createProject("secret");
     secretRefProject = createProject("secretRef");
-    privilegedGroup = group(createGroup("privilegedGroup"));
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
 
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
-    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id).update();
 
-    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
-        .contains("snowden");
-
-    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroupUuid);
     block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
 
     deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        false,
-        privilegedGroup.getGroupUUID());
+    grant(secretRefProject, "refs/heads/secret/*", Permission.READ, false, privilegedGroupUuid);
     block(
         secretRefProject,
         "refs/heads/secret/*",
@@ -81,13 +77,8 @@
         SystemGroupBackend.REGISTERED_USERS);
 
     // Ref permission
-    grant(
-        normalProject,
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        privilegedGroup.getGroupUUID());
-    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroup.getGroupUUID());
+    grant(normalProject, "refs/*", Permission.VIEW_PRIVATE_CHANGES, false, privilegedGroupUuid);
+    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroupUuid);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
new file mode 100644
index 0000000..6c6ad3d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -0,0 +1,314 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckProjectIT extends AbstractDaemonTest {
+  private TestRepository<InMemoryRepository> serverSideTestRepo;
+
+  @Before
+  public void setUp() throws Exception {
+    serverSideTestRepo =
+        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
+  }
+
+  @Test
+  public void noProblem() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(testRepo.getRevWalk().parseCommit(commit));
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toList()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(commit);
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void maxCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.maxCommits = 2;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void skipCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.skipCommits = 1;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noBranch() throws Exception {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branch is required");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void nonExistingBranch() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("branch 'non-existing' not found");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void branchPrefixCanBeOmitted() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("master");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void setLimitForMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT;
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void tooLargeMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT + 1;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "max commits can at most be set to "
+            + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    RevCommit commit =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message("A change")
+            .author(admin.getIdent())
+            .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()))
+            .create();
+    pushHead(testRepo, "refs/for/master");
+    return commit;
+  }
+
+  private static CheckProjectInput checkProjectInputForAutoCloseableCheck(String branch) {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+    input.autoCloseableChangesCheck.branch = branch;
+    return input;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 8479dd1..18888ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -16,6 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
@@ -23,6 +27,7 @@
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -67,7 +72,7 @@
   @Before
   public void addProjectIndexedCounter() {
     projectIndexedCounter = new ProjectIndexedCounter();
-    projectIndexedCounterHandle = projectIndexedListeners.add(projectIndexedCounter);
+    projectIndexedCounterHandle = projectIndexedListeners.add("gerrit", projectIndexedCounter);
   }
 
   @After
@@ -368,7 +373,7 @@
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("set HEAD not permitted for refs/heads/test");
+    exception.expectMessage("not permitted: set HEAD on refs/heads/test");
     gApi.projects().name(project.get()).head("test");
   }
 
@@ -410,6 +415,185 @@
         ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
   }
 
+  @Test
+  public void maxObjectSizeIsNotSetByDefault() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeCanBeSetAndCleared() throws Exception {
+    // Set a value
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    // Clear the value
+    info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeIsInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(INHERITED_FROM_PARENT, project));
+  }
+
+  @Test
+  public void maxObjectSizeIsNotInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenNotSetOnParent() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideParentProjectWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(OVERRIDDEN_BY_PARENT, project));
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeIsInheritedFromGlobalConfig() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "300k")
+  public void inheritedMaxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideGlobalConfigWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("300k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("300k");
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+  }
+
+  @Test
+  public void invalidMaxObjectSizeIsRejected() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("100 foo");
+    setMaxObjectSize("100 foo");
+  }
+
+  private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
+    return gApi.projects().name(name.get()).config(input);
+  }
+
+  private ConfigInfo getConfig(Project.NameKey name) throws Exception {
+    return gApi.projects().name(name.get()).config();
+  }
+
+  private ConfigInfo getConfig() throws Exception {
+    return getConfig(project);
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -427,6 +611,16 @@
     return input;
   }
 
+  private ConfigInfo setMaxObjectSize(String value) throws Exception {
+    return setMaxObjectSize(project, value);
+  }
+
+  private ConfigInfo setMaxObjectSize(Project.NameKey name, String value) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.maxObjectSizeLimit = value;
+    return setConfig(name, input);
+  }
+
   private static class ProjectIndexedCounter implements ProjectIndexedListener {
     private final AtomicLongMap<String> countsByProject = AtomicLongMap.create();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index ec4f327..3295f1a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -17,13 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import org.junit.Test;
 
 @NoHttpd
@@ -38,6 +41,40 @@
   }
 
   @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentNotAllowedForNonOwners() throws Exception {
+    String parent = createProject("parent", null, true).get();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).parent(parent);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentAllowedByAdminWhenAllowProjectOwnersEnabled() throws Exception {
+    String parent = createProject("parent", null, true).get();
+
+    gApi.projects().name(project.get()).parent(parent);
+    assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+
+    // When the parent name is not explicitly set, it should be
+    // set to "All-Projects".
+    gApi.projects().name(project.get()).parent(null);
+    assertThat(gApi.projects().name(project.get()).parent())
+        .isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.allowProjectOwnersToChangeParent", value = "true")
+  public void setParentAllowedForOwners() throws Exception {
+    String parent = createProject("parent", null, true).get();
+    setApiUser(user);
+    grant(project, "refs/*", Permission.OWNER, false, SystemGroupBackend.REGISTERED_USERS);
+    gApi.projects().name(project.get()).parent(parent);
+    assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+  }
+
+  @Test
   public void setParent() throws Exception {
     String parent = createProject("parent", null, true).get();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 53cc5ad..057f837 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
@@ -2343,6 +2346,126 @@
     assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
   }
 
+  @Test
+  public void diffOfUnmodifiedFileWithWholeFileContextReturnsFileContents() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithCommentAndWholeFileContextReturnsFileContents()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
+            .withBase(initialPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    assertThat(diffInfo).content().isEmpty();
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // This behavior has been present in Gerrit for quite some time. It differs from the results
+    // returned for other cases (e.g. requesting the diff with whole file context for an unmodified
+    // file; requesting the diff with whole file context for a non-existent file). However, it's not
+    // completely clear what should be returned. The closest would be the result of a file deletion
+    // but that might also be misleading for users as actually a file rename occurred. In fact,
+    // requesting the diff result for the old file name of a renamed file is not a reasonable use
+    // case at all. We at least guarantee that we don't run into an internal error.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileWithCommentOnOldFileYieldsReasonableResult()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // See comment for requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult().
+    // This test should additionally ensure that we also don't run into an internal error when
+    // a comment is present.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  private static CommentInput createCommentInput(
+      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+    CommentInput comment = new CommentInput();
+    comment.range = new Comment.Range();
+    comment.range.startLine = startLine;
+    comment.range.startCharacter = startCharacter;
+    comment.range.endLine = endLine;
+    comment.range.endCharacter = endCharacter;
+    comment.message = message;
+    return comment;
+  }
+
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 8a3d0f3..ca4304e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -920,6 +920,7 @@
     CountDownLatch reindexed = new CountDownLatch(1);
     RegistrationHandle handle =
         changeIndexedListeners.add(
+            "gerrit",
             new ChangeIndexedListener() {
               @Override
               public void onChangeIndexed(String projectName, int id) {
@@ -1086,6 +1087,7 @@
     WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
     RegistrationHandle handle =
         patchSetLinks.add(
+            "gerrit",
             new PatchSetWebLink() {
               @Override
               public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index b4ae8a2..f1e67c1 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -103,6 +103,7 @@
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -212,6 +213,24 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void validateConnected() throws Exception {
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    testRepo.reset(c);
+
+    String r = "refs/heads/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    RevCommit amended =
+        testRepo.amend(c).message("different initial commit").insertChangeId().create();
+    testRepo.reset(amended);
+    r = "refs/for/master";
+    pr = pushHead(testRepo, r, false);
+    assertPushRejected(pr, r, "no common ancestry");
+  }
+
+  @Test
   public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
     // delete refs/meta/config
     try (Repository repo = repoManager.openRepository(project);
@@ -301,13 +320,14 @@
     r2.assertOkStatus();
     r2.assertChange(Change.Status.NEW, null);
     r2.assertMessage(
-        "New changes:\n"
+        "success\n"
+            + "\n"
+            + "New changes:\n"
             + "  "
             + url
             + id2
             + " another commit\n"
             + "\n"
-            + "\n"
             + "Updated changes:\n"
             + "  "
             + url
@@ -339,6 +359,20 @@
   }
 
   @Test
+  public void pushWithoutChangeIdDeprecated() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message("A change")
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()))
+        .create();
+    PushResult result = pushHead(testRepo, "refs/for/master");
+    assertThat(result.getMessages()).contains("warning: pushing without Change-Id is deprecated");
+  }
+
+  @Test
   public void autocloseByChangeId() throws Exception {
     // Create a change
     PushOneCommit.Result r = pushTo("refs/for/master");
@@ -1155,7 +1189,7 @@
         pushFactory.create(
             db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
-    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
+    r.assertErrorStatus("not Signed-off-by author/committer/uploader in message footer");
   }
 
   @Test
@@ -1228,7 +1262,7 @@
 
     // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
     // care that there is a new change.
-    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
+    assertThat(pr.getMessages()).containsMatch("changes: .*new: 1.*done");
     assertTwoChangesWithSameRevision(r);
   }
 
@@ -1359,7 +1393,7 @@
   private void testPushWithoutChangeId() throws Exception {
     RevCommit c = createCommit(testRepo, "Message without Change-Id");
     assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
-    pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
     pushForReviewOk(testRepo);
@@ -1384,8 +1418,9 @@
     r = push.to("refs/changes/" + r.getChange().change().getId().get());
     r.assertErrorStatus(
         String.format(
-            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG,
-            r.getCommit().abbreviate(RevId.ABBREV_LEN).name()));
+            "commit %s: %s",
+            r.getCommit().abbreviate(RevId.ABBREV_LEN).name(),
+            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG));
   }
 
   @Test
@@ -1406,10 +1441,10 @@
             + "\n"
             + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
             + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
   }
 
   @Test
@@ -1425,10 +1460,10 @@
 
   private void testpushWithInvalidChangeId() throws Exception {
     createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -1449,19 +1484,19 @@
         "Message with invalid Change-Id\n"
             + "\n"
             + "Change-Id: I0000000000000000000000000000000000000000\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
   }
 
   @Test
   public void pushWithChangeIdInSubjectLine() throws Exception {
     createCommit(testRepo, "Change-Id: I1234000000000000000000000000000000000000");
-    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
   }
 
   @Test
@@ -2037,7 +2072,8 @@
     assertPushOk(pushHead(testRepo, master), master);
 
     commits.addAll(initChanges(3));
-    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+    assertPushRejected(
+        pushHead(testRepo, master), master, "more than 2 commits, and skip-validation not set");
 
     grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
     PushResult r =
@@ -2079,7 +2115,7 @@
 
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
-    assertPushRejected(pr, ref, "prohibited by Gerrit: create not permitted for " + ref);
+    assertPushRejected(pr, ref, "prohibited by Gerrit: not permitted: create");
 
     grant(project, "refs/changes/*", Permission.CREATE);
     grant(project, "refs/changes/*", Permission.PUSH);
@@ -2101,11 +2137,11 @@
                 .push()
                 .setRefSpecs(
                     new RefSpec(noteDbCommit.name() + ":" + ref),
-                    new RefSpec(changeCommit.name() + ":refs/for/master"))
+                    new RefSpec(changeCommit.name() + ":refs/heads/permitted"))
                 .call());
 
     assertPushRejected(pr, ref, "NoteDb update requires -o notedb=allow");
-    assertPushOk(pr, "refs/for/master");
+    assertPushOk(pr, "refs/heads/permitted");
   }
 
   private DraftInput newDraft(String path, int line, String message) {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 252ec88..943b052 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -29,11 +29,22 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.StreamSupport;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -451,4 +462,47 @@
     RevCommit c = rw.parseCommit(commitId);
     return c.getAuthorIdent();
   }
+
+  protected void directUpdateSubmodule(String project, String refName, String path, AnyObjectId id)
+      throws Exception {
+    path = name(path);
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)));
+        ObjectInserter ins = serverRepo.newObjectInserter();
+        RevWalk rw = new RevWalk(serverRepo)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertThat(ref).named(refName).isNotNull();
+      ObjectId oldCommitId = ref.getObjectId();
+
+      DirCache dc = DirCache.newInCore();
+      DirCacheBuilder b = dc.builder();
+      b.addTree(
+          new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+      b.finish();
+      DirCacheEditor e = dc.editor();
+      e.add(
+          new PathEdit(path) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.GITLINK);
+              ent.setObjectId(id);
+            }
+          });
+      e.finish();
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.addParentId(oldCommitId);
+      cb.setTreeId(dc.writeTree(ins));
+      PersonIdent ident = serverIdent.get();
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      cb.setMessage("Direct update submodule " + path);
+      ObjectId newCommitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate ru = serverRepo.updateRef(refName);
+      ru.setExpectedOldObjectId(oldCommitId);
+      ru.setNewObjectId(newCommitId);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index 87ac022..d80faa8 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -51,7 +51,7 @@
         pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
     push2.setForce(true);
     PushOneCommit.Result r2 = push2.to("refs/heads/master");
-    r2.assertErrorStatus("need 'Force Push' privilege.");
+    r2.assertErrorStatus("not permitted: force update");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index af74657..907ad7f 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -46,6 +46,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.eclipse.jgit.transport.TrackingRefUpdate;
 import org.junit.Before;
 import org.junit.Test;
@@ -78,16 +79,37 @@
   }
 
   @Test
+  public void mixingMagicAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
+  public void mixingDirectChangesAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/changes/01/101");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/changes/01/101")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
   public void fastForwardUpdateDenied() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
             "Contact an administrator to fix the permissions");
@@ -98,19 +120,21 @@
   public void nonFastForwardUpdateDenied() throws Exception {
     ObjectId commit = testRepo.commit().create();
     PushResult r = push("+" + commit.name() + ":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("need 'Force Push' privilege.");
-    assertThat(r).hasNoMessages();
-    // TODO(dborowitz): Why does this not mention refs?
-    assertThat(r).hasProcessed(ImmutableMap.of());
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: force update");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
   @Test
   public void deleteDenied() throws Exception {
     PushResult r = push(":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("cannot delete references");
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
+            "error: branch refs/heads/master:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "User: admin",
@@ -124,8 +148,8 @@
     PushResult r = push("HEAD:refs/heads/newbranch");
     assertThat(r)
         .onlyRef("refs/heads/newbranch")
-        .isRejected("prohibited by Gerrit: create not permitted for refs/heads/newbranch");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: create");
+    assertThat(r).containsMessages("You need 'Create' rights to create new references.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -139,18 +163,17 @@
 
     testRepo.branch("HEAD").commit().create();
     PushResult r = push(":refs/heads/foo", ":refs/heads/bar", "HEAD:refs/heads/master");
-    assertThat(r).ref("refs/heads/foo").isRejected("cannot delete references");
-    assertThat(r).ref("refs/heads/bar").isRejected("cannot delete references");
+    assertThat(r).ref("refs/heads/foo").isRejected("prohibited by Gerrit: not permitted: delete");
+    assertThat(r).ref("refs/heads/bar").isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .ref("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branches refs/heads/foo, refs/heads/bar:",
+            "error: branches refs/heads/foo, refs/heads/bar:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
             "Contact an administrator to fix the permissions");
@@ -185,11 +208,10 @@
         // ReceiveCommits theoretically has a different message when a WRITE_CONFIG check fails, but
         // it never gets there, since DefaultPermissionBackend special-cases refs/meta/config and
         // denies UPDATE if the user is not a project owner.
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/meta/config:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/meta/config:",
             "Configuration changes can only be pushed by project owners",
             "who also have 'Push' rights on refs/meta/config",
             "User: admin",
@@ -212,14 +234,12 @@
     PushResult r = push("HEAD:refs/for/master");
     assertThat(r)
         .onlyRef("refs/for/master")
-        .isRejected("create change not permitted for refs/heads/master");
+        .isRejected("prohibited by Gerrit: not permitted: create change on refs/heads/master");
     assertThat(r)
-        .hasMessages(
-            "Branch refs/heads/master:",
-            "You need 'Push' rights to upload code review requests.",
-            "Verify that you are pushing to the right branch.",
-            "User: admin",
-            "Contact an administrator to fix the permissions");
+        .containsMessages(
+            "error: branch refs/for/master:",
+            "You need 'Create Change' rights to upload code review requests.",
+            "Verify that you are pushing to the right branch.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -234,8 +254,10 @@
     PushResult r = push("HEAD:refs/for/master%submit");
     assertThat(r)
         .onlyRef("refs/for/master%submit")
-        .isRejected("update by submit not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: update by submit on refs/heads/master");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -269,8 +291,11 @@
         push(c -> c.setPushOptions(ImmutableList.of("skip-validation")), "HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("skip validation not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: skip validation");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Forge Author', 'Forge Server', 'Forge Committer'",
+            "and 'Push Merge' rights to skip validation.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 0705dca..700b18b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -37,11 +37,8 @@
 import org.eclipse.jgit.api.errors.InvalidRemoteException;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -62,38 +59,6 @@
   }
 
   @Test
-  public void submitOnPushWithTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.CREATE);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.Tag tag = new PushOneCommit.Tag("v1.0");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
-  public void submitOnPushWithAnnotatedTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.AnnotatedTag tag =
-        new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
     grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
 
@@ -158,7 +123,7 @@
   @Test
   public void submitOnPushNotAllowed_Error() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit");
   }
 
   @Test
@@ -170,7 +135,7 @@
         push(
             "refs/for/master%submit",
             PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit ");
   }
 
   @Test
@@ -385,31 +350,6 @@
     }
   }
 
-  private void assertTag(Project.NameKey project, String branch, PushOneCommit.Tag tag)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Ref tagRef = repo.findRef(tag.name);
-      assertThat(tagRef).isNotNull();
-      ObjectId taggedCommit = null;
-      if (tag instanceof PushOneCommit.AnnotatedTag) {
-        PushOneCommit.AnnotatedTag annotatedTag = (PushOneCommit.AnnotatedTag) tag;
-        try (RevWalk rw = new RevWalk(repo)) {
-          RevObject object = rw.parseAny(tagRef.getObjectId());
-          assertThat(object).isInstanceOf(RevTag.class);
-          RevTag tagObject = (RevTag) object;
-          assertThat(tagObject.getFullMessage()).isEqualTo(annotatedTag.message);
-          assertThat(tagObject.getTaggerIdent()).isEqualTo(annotatedTag.tagger);
-          taggedCommit = tagObject.getObject();
-        }
-      } else {
-        taggedCommit = tagRef.getObjectId();
-      }
-      ObjectId headCommit = repo.exactRef(branch).getObjectId();
-      assertThat(taggedCommit).isNotNull();
-      assertThat(taggedCommit).isEqualTo(headCommit);
-    }
-  }
-
   private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 847004f..98e3cae 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -29,6 +29,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -655,6 +656,65 @@
     }
   }
 
+  @Test
+  public void updateOnlyRelevantSubmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo1 = createProjectWithPush("subscribed-to-project-1");
+    TestRepository<?> subRepo2 = createProjectWithPush("subscribed-to-project-2");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-1", "master");
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Push once to initialize submodules.
+    ObjectId subTip2 = pushChangeTo(subRepo2, "master");
+    ObjectId subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+
+    directUpdateRef("subscribed-to-project-2", "refs/heads/master");
+    subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+  }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    // Push once to initialize submodule.
+    ObjectId subTip = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subTip);
+
+    // Write an invalid SHA-1 directly to the gitlink.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "subscribed-to-project", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+
+    // Push succeeds, but gitlink update is skipped.
+    pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+  }
+
+  private ObjectId directUpdateRef(String project, String ref) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)))) {
+      return new TestRepository<>(serverRepo).branch(ref).commit().create().copy();
+    }
+  }
+
   private void testSubmoduleSubjectCommitMessageAndExpectTruncation() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 2812c86..eef3295 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -856,4 +856,44 @@
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Write an invalid SHA-1 directly to one of the gitlinks.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+
+    String topic = "same-topic";
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
+
+    String changeId1 = getChangeId(sub1, sub1Id).get();
+    String changeId2 = getChangeId(sub2, sub2Id).get();
+    approve(changeId1);
+    approve(changeId2);
+
+    gApi.changes().id(changeId1).current().submit();
+
+    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
+
+    // sub1 was skipped but sub2 succeeded.
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index c90b3d3..0d5d2cd 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -47,13 +47,8 @@
   }
 
   @ConfigSuite.Config
-  public static Config elasticsearchV6_2() {
-    return getConfig(ElasticVersion.V6_2);
-  }
-
-  @ConfigSuite.Config
-  public static Config elasticsearchV6_3() {
-    return getConfig(ElasticVersion.V6_3);
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
index b4940bc..b94a98d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -4,7 +4,10 @@
     srcs = glob(["*IT.java"]),
     group = "rest_bindings",
     labels = ["rest"],
-    deps = [":util"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/logging",
+    ],
 )
 
 java_library(
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
new file mode 100644
index 0000000..137dc21
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -0,0 +1,299 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_CREATED;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.Expect;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.httpd.restapi.ParameterParser;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class TraceIT extends AbstractDaemonTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+  @Inject private WorkQueue workQueue;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TraceValidatingCommitValidationListener commitValidationListener;
+  private RegistrationHandle commitValidationRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+    commitValidationListener = new TraceValidatingCommitValidationListener();
+    commitValidationRegistrationHandle =
+        commitValidationListeners.add("gerrit", commitValidationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+    commitValidationRegistrationHandle.remove();
+  }
+
+  @Test
+  public void restCallWithoutTrace() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new1");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParam() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParamAndProvidedTraceId() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=issue/123");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeader() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeaderAndProvidedTraceId() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParamAndTraceHeader() throws Exception {
+    // trace ID only specified by trace header
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // trace ID only specified by trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new7?trace=issue/123", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // same trace ID specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new8?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // different trace IDs specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new9?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeaders(RestApiServlet.X_GERRIT_TRACE))
+        .containsExactly("issue/123", "issue/456");
+    assertThat(projectCreationListener.traceIds).containsExactly("issue/123", "issue/456");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void pushWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=issue/123"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNull();
+    assertThat(commitValidationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void pushForReviewWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isNotNull();
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithTraceAndProvidedTraceId() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=issue/123"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
+    assertThat(commitValidationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void workQueueCopyLoggingContext() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+
+      workQueue
+          .createQueue(1, "test-queue")
+          .submit(
+              () -> {
+                // Verify that the tags and force logging flag have been propagated to the new
+                // thread.
+                SortedMap<String, SortedSet<Object>> threadTagMap =
+                    LoggingContext.getInstance().getTags().asMap();
+                expect.that(threadTagMap.keySet()).containsExactly("foo");
+                expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                expect
+                    .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+                    .isTrue();
+              })
+          .get();
+
+      // Verify that tags and force logging flag in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    String traceId;
+    ImmutableSet<String> traceIds;
+    Boolean isLoggingForced;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.traceIds = LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID");
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+    }
+  }
+
+  private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
+    String traceId;
+    Boolean isLoggingForced;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index f031729..b74a0d7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -60,7 +60,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
@@ -105,8 +104,6 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
@@ -133,8 +130,6 @@
             .fromJson(
                 response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
 
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
     assertThat(results).containsExactlyElementsIn(expectedIdInfos);
   }
 
@@ -927,7 +922,7 @@
   private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
@@ -980,7 +975,7 @@
 
   private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
     assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
-    assertThat(update.getMessage()).isEqualTo(msg);
+    assertThat(update.getMessage()).contains(msg);
   }
 
   private AutoCloseable createFailOnLoadContext() {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 61a2d84..af11149 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -27,6 +27,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -154,11 +155,12 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    assertThat(getRemoteHead()).isNull();
     PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
     Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmitPreview = getRemoteHead();
-    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(headAfterSubmitPreview).isNull();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
@@ -1070,6 +1072,45 @@
     change.current().submit();
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Change 1", ImmutableMap.of())
+            .to("refs/for/master");
+    change.assertOkStatus();
+    // TODO(dborowitz): Use EMPTY_TREE_ID after upgrading to https://git.eclipse.org/r/127473
+    assertThat(change.getCommit().getTree())
+        .isEqualTo(ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904"));
+
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
@@ -1283,7 +1324,7 @@
 
   protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
     assertThat(onSubmitValidatorHandle).isNull();
-    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
+    onSubmitValidatorHandle = onSubmitValidationListeners.add("gerrit", listener);
   }
 
   private String getLatestDiff(Repository repo) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 171babd..f45f9dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -331,7 +331,7 @@
     assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     Map<String, ActionInfo> newActions =
         gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
@@ -380,7 +380,7 @@
     assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     // Test different codepaths within ActionJson...
     // ...via revision API.
@@ -443,7 +443,7 @@
     assertThat(origActions.get("description").label).isEqualTo("Edit Description");
 
     Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
+    visitorHandle = actionVisitors.add("gerrit", v);
 
     // Unlike for the current revision, actions for old revisions are only available via the
     // revision API.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 09c7e0b..9218336 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -101,9 +101,7 @@
     ChangeInput ci = newChangeInput(ChangeStatus.NEW);
     ci.subject = "Subject\n\nChange-Id: I0000000000000000000000000000000000000000";
     assertCreateFails(
-        ci,
-        ResourceConflictException.class,
-        "invalid Change-Id line format in commit message footer");
+        ci, ResourceConflictException.class, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -113,7 +111,7 @@
     assertCreateFails(
         ci,
         ResourceConflictException.class,
-        "missing subject; Change-Id must be in commit message footer");
+        "missing subject; Change-Id must be in message footer");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 8cd1770..2182b2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -97,6 +97,7 @@
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index e8b8fe8..3d8d06e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -87,6 +87,7 @@
 
     RegistrationHandle handle =
         changeMessageModifiers.add(
+            "gerrit",
             new ChangeMessageModifier() {
               @Override
               public String onSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 3534959..a64305c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -89,6 +89,7 @@
   public void webLink() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
+            "gerrit",
             new FileHistoryWebLink() {
               @Override
               public WebLinkInfo getFileHistoryWebLink(
@@ -111,6 +112,7 @@
   public void webLinkNoRefsMetaConfig() throws Exception {
     RegistrationHandle handle =
         fileHistoryWebLinkDynamicSet.add(
+            "gerrit",
             new FileHistoryWebLink() {
               @Override
               public WebLinkInfo getFileHistoryWebLink(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 19f6295..1eea84b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -46,7 +46,7 @@
         pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 0017e08..df89686 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -61,7 +61,7 @@
   @Test
   public void createBranch_Forbidden() throws Exception {
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -85,7 +85,7 @@
   @Test
   public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -93,7 +93,7 @@
     grantOwner();
     blockCreateReference();
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5e1b0bf..b426a37 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -193,7 +193,7 @@
   private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     branch(branch).delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 330f2b8..c1bd8f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -75,7 +75,7 @@
       project().deleteBranches(input);
       fail("Expected AuthException");
     } catch (AuthException e) {
-      assertThat(e).hasMessageThat().isEqualTo("delete not permitted for refs/heads/test-1");
+      assertThat(e).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
     }
     setApiUser(admin);
     assertBranches(BRANCHES);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 0cbbe44..3ae0b44 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -125,7 +125,7 @@
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     tag().delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 714751d..d4edc0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -254,7 +254,7 @@
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
-    exception.expectMessage("create not permitted");
+    exception.expectMessage("not permitted: create");
     tag(input.ref).create(input);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 14b3858..f4a833f 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -76,6 +76,7 @@
 
     eventListenerRegistration =
         source.add(
+            "gerrit",
             new CommentAddedListener() {
               @Override
               public void onCommentAdded(Event event) {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
index a5d78c6..87c5ace 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -610,7 +610,7 @@
   }
 
   private void addListener(NotesMigrationStateListener listener) {
-    addedListeners.add(listeners.add(listener));
+    addedListeners.add(listeners.add("gerrit", listener));
   }
 
   private ImmutableSortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
deleted file mode 100644
index bcae987..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.reviewdb.client.Change;
-import java.io.File;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Test;
-
-@UseLocalDisk
-public class ReflogIT extends AbstractDaemonTest {
-  @Test
-  public void guessRestApiInReflog() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
-      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
-      if (!log.exists()) {
-        log.getParentFile().mkdirs();
-        assertThat(log.createNewFile()).isTrue();
-      }
-
-      gApi.changes().id(id.get()).topic("foo");
-      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
-      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 45b7767..8b0c56b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -82,6 +82,7 @@
 
     eventListenerRegistration =
         source.add(
+            "gerrit",
             new CommentAddedListener() {
               @Override
               public void onCommentAdded(Event event) {
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
new file mode 100644
index 0000000..8abb59d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.testing.Util;
+import java.io.File;
+import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+@UseLocalDisk
+public class ReflogIT extends AbstractDaemonTest {
+  @Test
+  public void guessRestApiInReflog() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
+    }
+  }
+
+  @Test
+  public void reflogUpdatedBySubmittingChange() throws Exception {
+    BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
+    List<ReflogEntryInfo> reflog = branchApi.reflog();
+    assertThat(reflog).isNotEmpty();
+
+    // Current number of entries in the reflog
+    int refLogLen = reflog.size();
+
+    // Create and submit a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revision = r.getCommit().name();
+    ReviewInput in = ReviewInput.approve();
+    gApi.changes().id(changeId).revision(revision).review(in);
+    gApi.changes().id(changeId).revision(revision).submit();
+
+    // Submitting the change causes a new entry in the reflog
+    reflog = branchApi.reflog();
+    assertThat(reflog).hasSize(refLogLen + 1);
+  }
+
+  @Test
+  public void regularUserIsNotAllowedToGetReflog() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void ownerUserIsAllowedToGetReflog() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("get-reflog"));
+    groupApi.addMembers("user");
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(), Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
+      u.save();
+    }
+
+    setApiUser(user);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void adminUserIsAllowedToGetReflog() throws Exception {
+    setApiUser(admin);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
new file mode 100644
index 0000000..d1b05e3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+@NoHttpd
+public class IgnoreSelfApprovalRuleIT extends AbstractDaemonTest {
+  @Inject private IgnoreSelfApprovalRule rule;
+
+  @Test
+  public void blocksWhenUploaderIsOnlyApprover() throws Exception {
+    enableRule("Code-Review", true);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+
+    assertThat(submitRecords).hasSize(1);
+    SubmitRecord result = submitRecords.iterator().next();
+    assertThat(result.status).isEqualTo(SubmitRecord.Status.NOT_READY);
+    assertThat(result.labels).isNotEmpty();
+    assertThat(result.requirements)
+        .containsExactly(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+  }
+
+  @Test
+  public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
+    enableRule("Code-Review", true);
+
+    // Create change as user
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    // Approve as admin
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  @Test
+  public void doesNothingByDefault() throws Exception {
+    enableRule("Code-Review", false);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  private void enableRule(String labelName, boolean newState) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Map<String, LabelType> localLabelSections = u.getConfig().getLabelSections();
+      if (localLabelSections.isEmpty()) {
+        localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
+      }
+      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 25bb7a6..ed3cdbc 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -50,7 +50,7 @@
   @Before
   public void addChangeIndexedCounter() {
     changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
   }
 
   @After
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index b195ecc..a01cd3e 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -17,6 +17,7 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/server/logging",
         "//lib/commons:compress",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
index 7a80f2e..2b00718 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
@@ -42,6 +42,6 @@
         pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 95da5a6..9d69955 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -46,13 +46,8 @@
   }
 
   @ConfigSuite.Config
-  public static Config elasticsearchV6_2() {
-    return getConfig(ElasticVersion.V6_2);
-  }
-
-  @ConfigSuite.Config
-  public static Config elasticsearchV6_3() {
-    return getConfig(ElasticVersion.V6_3);
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index cc86c0b..e603413 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.acceptance.ssh;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -28,7 +30,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.sshd.Commands;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
@@ -39,7 +40,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // TODO: It would be better to dynamically generate these lists
-  private static final List<String> COMMON_ROOT_COMMANDS =
+  private static final ImmutableList<String> COMMON_ROOT_COMMANDS =
       ImmutableList.of(
           "apropos",
           "close-connection",
@@ -57,7 +58,7 @@
           "show-queue",
           "version");
 
-  private static final List<String> MASTER_ONLY_ROOT_COMMANDS =
+  private static final ImmutableList<String> MASTER_ONLY_ROOT_COMMANDS =
       ImmutableList.of(
           "ban-commit",
           "create-account",
@@ -79,19 +80,12 @@
           "stream-events",
           "test-submit");
 
-  private static final Map<String, List<String>> MASTER_COMMANDS =
+  private static final ImmutableMap<String, List<String>> MASTER_COMMANDS =
       ImmutableMap.of(
           Commands.ROOT,
-          ImmutableList.copyOf(
-              new ArrayList<String>() {
-                private static final long serialVersionUID = 1L;
-
-                {
-                  addAll(COMMON_ROOT_COMMANDS);
-                  addAll(MASTER_ONLY_ROOT_COMMANDS);
-                  Collections.sort(this);
-                }
-              }),
+          Streams.concat(COMMON_ROOT_COMMANDS.stream(), MASTER_ONLY_ROOT_COMMANDS.stream())
+              .sorted()
+              .collect(toImmutableList()),
           "index",
           ImmutableList.of(
               "changes", "changes-in-project"), // "activate" and "start" are not included
@@ -102,7 +96,7 @@
           "test-submit",
           ImmutableList.of("rule", "type"));
 
-  private static final Map<String, List<String>> SLAVE_COMMANDS =
+  private static final ImmutableMap<String, List<String>> SLAVE_COMMANDS =
       ImmutableMap.of(
           Commands.ROOT,
           COMMON_ROOT_COMMANDS,
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
new file mode 100644
index 0000000..899b0cf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -0,0 +1,92 @@
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+public class SshTraceIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add("gerrit", projectCreationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+  }
+
+  @Test
+  public void sshCallWithoutTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project new1");
+    adminSshSession.assertSuccess();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.foundTraceId).isFalse();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+  }
+
+  @Test
+  public void sshCallWithTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace new2");
+
+    // The trace ID is written to stderr.
+    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void sshCallWithTraceAndProvidedTraceId() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace --trace-id issue/123 new3");
+
+    // The trace ID is written to stderr.
+    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void sshCallWithTraceIdAndWithoutTraceFails() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace-id issue/123 new3");
+    adminSshSession.assertFailure("A trace ID can only be set if --trace was specified.");
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    String traceId;
+    Boolean foundTraceId;
+    Boolean isLoggingForced;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.foundTraceId = traceId != null;
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
new file mode 100644
index 0000000..954b0e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -0,0 +1,656 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Objects;
+import java.util.Optional;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class GroupOperationsImplTest extends AbstractDaemonTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Inject private AccountOperations accountOperations;
+
+  @Inject private GroupOperationsImpl groupOperations;
+
+  private int uniqueGroupNameIndex;
+
+  @Test
+  public void groupCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isNotEmpty();
+  }
+
+  @Test
+  public void twoGroupsWithoutAnyParametersDoNotClash() throws Exception {
+    AccountGroup.UUID groupUuid1 = groupOperations.newGroup().create();
+    AccountGroup.UUID groupUuid2 = groupOperations.newGroup().create();
+
+    TestGroup group1 = groupOperations.group(groupUuid1).get();
+    TestGroup group2 = groupOperations.group(groupUuid2).get();
+    assertThat(group1.groupUuid()).isNotEqualTo(group2.groupUuid());
+  }
+
+  @Test
+  public void groupCreatedByTestApiCanBeRetrievedViaOfficialApi() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("unique group created via test API").create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isEqualTo("unique group created via test API");
+  }
+
+  @Test
+  public void specifiedNameIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("XYZ-123-this-name-must-be-unique").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.name).isEqualTo("XYZ-123-this-name-must-be-unique");
+  }
+
+  @Test
+  public void specifiedDescriptionIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("All authenticated users").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isEqualTo("All authenticated users");
+  }
+
+  @Test
+  public void requestingNoDescriptionIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isNull();
+  }
+
+  @Test
+  public void specifiedOwnerIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID ownerGroupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(ownerGroupUuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.ownerId).isEqualTo(ownerGroupUuid.get());
+  }
+
+  @Test
+  public void specifiedVisibilityIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    GroupInfo foundGroup1 = getGroupFromServer(group1Uuid);
+    GroupInfo foundGroup2 = getGroupFromServer(group2Uuid);
+    assertThat(foundGroup1.options.visibleToAll).isTrue();
+    // False == null
+    assertThat(foundGroup2.options.visibleToAll).isNull();
+  }
+
+  @Test
+  public void specifiedMembersAreRespectedForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+    Account.Id account3Id = accountOperations.newAccount().create();
+    Account.Id account4Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .members(account1Id, account2Id)
+            .addMember(account3Id)
+            .addMember(account4Id)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id, account3Id, account4Id);
+  }
+
+  @Test
+  public void directlyAddingMembersIsPossibleForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addMember(account1Id).addMember(account2Id).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id);
+  }
+
+  @Test
+  public void requestingNoMembersIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members).isEmpty();
+  }
+
+  @Test
+  public void specifiedSubgroupsAreRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group3Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group4Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .subgroups(group1Uuid, group2Uuid)
+            .addSubgroup(group3Uuid)
+            .addSubgroup(group4Uuid)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid, group3Uuid, group4Uuid);
+  }
+
+  @Test
+  public void directlyAddingSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addSubgroup(group1Uuid).addSubgroup(group2Uuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid);
+  }
+
+  @Test
+  public void requestingNoSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes).isEmpty();
+  }
+
+  @Test
+  public void existingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID groupUuid = createGroupInServer(createArbitraryGroupInput());
+
+    boolean exists = groupOperations.group(groupUuid).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    boolean exists = groupOperations.group(notExistingGroupUuid).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingGroupFails() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    expectedException.expect(IllegalStateException.class);
+    groupOperations.group(notExistingGroupUuid).get();
+  }
+
+  @Test
+  public void groupNotCreatedByTestApiCanBeRetrieved() throws Exception {
+    GroupInput input = createArbitraryGroupInput();
+    input.name = "unique group not created via test API";
+    AccountGroup.UUID groupUuid = createGroupInServer(input);
+
+    TestGroup foundGroup = groupOperations.group(groupUuid).get();
+
+    assertThat(foundGroup.groupUuid()).isEqualTo(groupUuid);
+    assertThat(foundGroup.name()).isEqualTo("unique group not created via test API");
+  }
+
+  @Test
+  public void uuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID foundGroupUuid = groupOperations.group(groupUuid).get().groupUuid();
+
+    assertThat(foundGroupUuid).isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void nameOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    String groupName = groupOperations.group(groupUuid).get().name();
+
+    assertThat(groupName).isEqualTo("ABC-789-this-name-must-be-unique");
+  }
+
+  @Test
+  public void nameKeyOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    AccountGroup.NameKey groupName = groupOperations.group(groupUuid).get().nameKey();
+
+    assertThat(groupName).isEqualTo(new AccountGroup.NameKey("ABC-789-this-name-must-be-unique"));
+  }
+
+  @Test
+  public void descriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .description("This is a very detailed description of this group.")
+            .create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).hasValue("This is a very detailed description of this group.");
+  }
+
+  @Test
+  public void emptyDescriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("owner group");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID ownerGroupUuid = groupOperations.group(groupUuid).get().ownerGroupUuid();
+
+    assertThat(ownerGroupUuid).isEqualTo(originalOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID visibleGroupUuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID invisibleGroupUuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    TestGroup visibleGroup = groupOperations.group(visibleGroupUuid).get();
+    TestGroup invisibleGroup = groupOperations.group(invisibleGroupUuid).get();
+
+    assertThat(visibleGroup.visibleToAll()).named("visibility of visible group").isTrue();
+    assertThat(invisibleGroup.visibleToAll()).named("visibility of invisible group").isFalse();
+  }
+
+  @Test
+  public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
+    GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group.id);
+
+    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+
+    assertThat(createdOn).isEqualTo(group.createdOn);
+  }
+
+  @Test
+  public void membersOfExistingGroupCanBeRetrieved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId3 = new Account.Id(3000);
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().members(memberId1, memberId2, memberId3).create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).containsExactly(memberId1, memberId2, memberId3);
+  }
+
+  @Test
+  public void emptyMembersOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void subgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2, subgroupUuid3).create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void emptySubgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void updateWithoutAnyParametersIsANoop() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+    TestGroup originalGroup = groupOperations.group(groupUuid).get();
+
+    groupOperations.group(groupUuid).forUpdate().update();
+
+    TestGroup updatedGroup = groupOperations.group(groupUuid).get();
+    assertThat(updatedGroup).isEqualTo(originalGroup);
+  }
+
+  @Test
+  public void updateWritesToInternalGroupSystem() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    String currentDescription = getGroupFromServer(groupUuid).description;
+    assertThat(currentDescription).isEqualTo("updated description");
+  }
+
+  @Test
+  public void nameCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().name("original name").create();
+
+    groupOperations.group(groupUuid).forUpdate().name("updated name").update();
+
+    String currentName = groupOperations.group(groupUuid).get().name();
+    assertThat(currentName).isEqualTo("updated name");
+  }
+
+  @Test
+  public void descriptionCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).hasValue("updated description");
+  }
+
+  @Test
+  public void descriptionCanBeCleared() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().clearDescription().update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidCanBeUpdated() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("original owner");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID updatedOwnerGroupUuid = new AccountGroup.UUID("updated owner");
+    groupOperations.group(groupUuid).forUpdate().ownerGroupUuid(updatedOwnerGroupUuid).update();
+
+    AccountGroup.UUID currentOwnerGroupUuid =
+        groupOperations.group(groupUuid).get().ownerGroupUuid();
+    assertThat(currentOwnerGroupUuid).isEqualTo(updatedOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().visibleToAll(true).create();
+
+    groupOperations.group(groupUuid).forUpdate().visibleToAll(false).update();
+
+    boolean visibleToAll = groupOperations.group(groupUuid).get().visibleToAll();
+    assertThat(visibleToAll).isFalse();
+  }
+
+  @Test
+  public void membersCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    groupOperations.group(groupUuid).forUpdate().addMember(memberId1).addMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1, memberId2);
+  }
+
+  @Test
+  public void membersCanBeRemoved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1);
+  }
+
+  @Test
+  public void memberAdditionAndRemovalCanBeMixed() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeMember(memberId1)
+        .addMember(memberId3)
+        .update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId2, memberId3);
+  }
+
+  @Test
+  public void membersCanBeCleared() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearMembers().update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void furtherMembersCanBeAddedAfterClearingAll() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations.group(groupUuid).forUpdate().clearMembers().addMember(memberId3).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId3);
+  }
+
+  @Test
+  public void subgroupsCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .addSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid2)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2);
+  }
+
+  @Test
+  public void subgroupsCanBeRemoved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeSubgroup(subgroupUuid2).update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1);
+  }
+
+  @Test
+  public void subgroupAdditionAndRemovalCanBeMixed() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void subgroupsCanBeCleared() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearSubgroups().update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void furtherSubgroupsCanBeAddedAfterClearingAll() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .clearSubgroups()
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid3);
+  }
+
+  private GroupInput createArbitraryGroupInput() {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("verifiers-" + uniqueGroupNameIndex++);
+    return groupInput;
+  }
+
+  private GroupInfo getGroupFromServer(AccountGroup.UUID groupUuid) throws RestApiException {
+    return gApi.groups().id(groupUuid.get()).detail();
+  }
+
+  private AccountGroup.UUID createGroupInServer(GroupInput input) throws RestApiException {
+    GroupInfo group = gApi.groups().create(input).detail();
+    return new AccountGroup.UUID(group.id);
+  }
+
+  private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
+    return new Correspondence<AccountInfo, Account.Id>() {
+      @Override
+      public boolean compare(AccountInfo actualAccount, Account.Id expectedId) {
+        Account.Id accountId =
+            Optional.ofNullable(actualAccount)
+                .map(account -> account._accountId)
+                .map(Account.Id::new)
+                .orElse(null);
+        return Objects.equals(accountId, expectedId);
+      }
+
+      @Override
+      public String toString() {
+        return "has ID";
+      }
+    };
+  }
+
+  private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
+    return new Correspondence<GroupInfo, AccountGroup.UUID>() {
+      @Override
+      public boolean compare(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
+        AccountGroup.UUID groupUuid =
+            Optional.ofNullable(actualGroup)
+                .map(group -> group.id)
+                .map(AccountGroup.UUID::new)
+                .orElse(null);
+        return Objects.equals(groupUuid, expectedUuid);
+      }
+
+      @Override
+      public String toString() {
+        return "has UUID";
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
new file mode 100644
index 0000000..6c3befb
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+  @Test
+  public void sortLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v1 = new LabelValue((short) 1, "One");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v0, v1));
+    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+  }
+
+  @Test
+  public void insertMissingLabelValues() {
+    LabelValue v0 = new LabelValue((short) 0, "Zero");
+    LabelValue v2 = new LabelValue((short) 2, "Two");
+    LabelValue v5 = new LabelValue((short) 5, "Five");
+    LabelType types = new LabelType("Label", ImmutableList.of(v2, v5, v0));
+    assertThat(types.getValues())
+        .containsExactly(
+            v0,
+            new LabelValue((short) 1, ""),
+            v2,
+            new LabelValue((short) 3, ""),
+            new LabelValue((short) 4, ""),
+            v5)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index f5614b4..8b3c08f 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -45,11 +45,13 @@
       case V2_4:
         return "elasticsearch:2.4.6-alpine";
       case V5_6:
-        return "docker.elastic.co/elasticsearch/elasticsearch:5.6.10";
+        return "docker.elastic.co/elasticsearch/elasticsearch:5.6.11";
       case V6_2:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4";
       case V6_3:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2";
+      case V6_4:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.0";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index 1d17b5b..b8154ce 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_3);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 7c5d2e2..3445b36 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_3);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 15b58e0..851b27d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_3);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 860dca6..b598a0a 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -29,21 +29,31 @@
     assertThat(ElasticVersion.forVersion("2.4.6")).isEqualTo(ElasticVersion.V2_4);
 
     assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
-    assertThat(ElasticVersion.forVersion("5.6.9")).isEqualTo(ElasticVersion.V5_6);
-    assertThat(ElasticVersion.forVersion("5.6.10")).isEqualTo(ElasticVersion.V5_6);
+    assertThat(ElasticVersion.forVersion("5.6.11")).isEqualTo(ElasticVersion.V5_6);
 
     assertThat(ElasticVersion.forVersion("6.2.0")).isEqualTo(ElasticVersion.V6_2);
     assertThat(ElasticVersion.forVersion("6.2.4")).isEqualTo(ElasticVersion.V6_2);
 
     assertThat(ElasticVersion.forVersion("6.3.0")).isEqualTo(ElasticVersion.V6_3);
-    assertThat(ElasticVersion.forVersion("6.3.1")).isEqualTo(ElasticVersion.V6_3);
+    assertThat(ElasticVersion.forVersion("6.3.2")).isEqualTo(ElasticVersion.V6_3);
+
+    assertThat(ElasticVersion.forVersion("6.4.0")).isEqualTo(ElasticVersion.V6_4);
+    assertThat(ElasticVersion.forVersion("6.4.1")).isEqualTo(ElasticVersion.V6_4);
   }
 
   @Test
   public void unsupportedVersion() throws Exception {
-    exception.expect(ElasticVersion.InvalidVersion.class);
+    exception.expect(ElasticVersion.UnsupportedVersion.class);
     exception.expectMessage(
-        "Invalid version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
+        "Unsupported version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
     ElasticVersion.forVersion("4.0.0");
   }
+
+  @Test
+  public void version6() throws Exception {
+    assertThat(ElasticVersion.V6_2.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_3.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_4.isV6()).isTrue();
+    assertThat(ElasticVersion.V5_6.isV6()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index 117e474..c86160f 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.extensions.registration;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.inject.Key;
+import com.google.inject.Provider;
 import com.google.inject.util.Providers;
+import java.util.Iterator;
 import org.junit.Test;
 
 public class DynamicSetTest {
@@ -40,7 +43,7 @@
   @Test
   public void containsTrueWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     assertThat(ds.contains(2)).isTrue(); // See above comment about ds.contains
   }
@@ -48,7 +51,7 @@
   @Test
   public void containsFalseWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
@@ -56,8 +59,8 @@
   @Test
   public void containsTrueWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
-    ds.add(4);
+    ds.add("gerrit", 2);
+    ds.add("gerrit", 4);
 
     assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
   }
@@ -65,8 +68,8 @@
   @Test
   public void containsFalseWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
-    ds.add(4);
+    ds.add("gerrit", 2);
+    ds.add("gerrit", 4);
 
     assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
@@ -74,12 +77,12 @@
   @Test
   public void containsDynamic() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    ds.add(2);
+    ds.add("gerrit", 2);
 
     Key<Integer> key = Key.get(Integer.class);
-    ReloadableRegistrationHandle<Integer> handle = ds.add(key, Providers.of(4));
+    ReloadableRegistrationHandle<Integer> handle = ds.add("gerrit", key, Providers.of(4));
 
-    ds.add(6);
+    ds.add("gerrit", 6);
 
     // At first, 4 is contained.
     assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
@@ -90,4 +93,49 @@
     // And now 4 should no longer be contained.
     assertThat(ds.contains(4)).isFalse(); // See above comment about ds.contains
   }
+
+  @Test
+  public void plugins() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    assertThat(ds.plugins()).containsExactly("bar", "foo").inOrder();
+  }
+
+  @Test
+  public void byPlugin() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    assertThat(ds.byPlugin("foo").stream().map(Provider::get).collect(toSet())).containsExactly(1);
+    assertThat(ds.byPlugin("bar").stream().map(Provider::get).collect(toSet()))
+        .containsExactly(2, 3);
+  }
+
+  @Test
+  public void entryIterator() {
+    DynamicSet<Integer> ds = new DynamicSet<>();
+    ds.add("foo", 1);
+    ds.add("bar", 2);
+    ds.add("bar", 3);
+
+    Iterator<DynamicSet.Entry<Integer>> entryIterator = ds.entries().iterator();
+    DynamicSet.Entry<Integer> next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("foo");
+    assertThat(next.getProvider().get()).isEqualTo(1);
+
+    next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("bar");
+    assertThat(next.getProvider().get()).isEqualTo(2);
+
+    next = entryIterator.next();
+    assertThat(next.getPluginName()).isEqualTo("bar");
+    assertThat(next.getProvider().get()).isEqualTo(3);
+
+    assertThat(entryIterator.hasNext()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
index ad8f4311..266f868 100644
--- a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -184,7 +184,7 @@
     }
 
     String cert = payload + new String(bout.toByteArray(), UTF_8);
-    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)));
+    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)), UTF_8);
     PushCertificateParser parser = new PushCertificateParser(repo, signedPushConfig);
     return parser.parse(reader);
   }
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 086dcc2..1c6559b0 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -76,7 +76,7 @@
    */
   private ReloadableRegistrationHandle<AllRequestFilter> addFilter(AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
-    return filters.add(key, Providers.of(filter));
+    return filters.add("gerrit", key, Providers.of(filter));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index edafeb3..307a23e 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.template.soy.data.SoyMapData;
 import java.net.URISyntaxException;
 import org.junit.Test;
 
 public class IndexServletTest {
-  class TestIndexServlet extends IndexServlet {
+  static class TestIndexServlet extends IndexServlet {
     private static final long serialVersionUID = 1L;
 
     TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
@@ -30,7 +31,7 @@
     }
 
     String getIndexSource() {
-      return new String(indexSource);
+      return new String(indexSource, UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index 14a7048..5597ed1 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -6,9 +6,9 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index:query_parser",
         "//lib:guava",
         "//lib:junit",
         "//lib/antlr:java-runtime",
diff --git a/javatests/com/google/gerrit/mail/BUILD b/javatests/com/google/gerrit/mail/BUILD
index 488bbcc..2fd8f24 100644
--- a/javatests/com/google/gerrit/mail/BUILD
+++ b/javatests/com/google/gerrit/mail/BUILD
@@ -22,7 +22,6 @@
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/org/eclipse/jgit:server",
-        "//lib:grappa",
         "//lib:gson",
         "//lib:guava-retrying",
         "//lib:gwtorm",
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 18d9c71..7be1827 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -45,16 +45,17 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//java/org/eclipse/jgit:server",
-        "//lib:grappa",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -63,6 +64,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 91cc2b7..6dd0f3e 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -55,7 +55,7 @@
     user = createNiceMock(IdentifiedUser.class);
     replay(user);
     backends = new DynamicSet<>();
-    backends.add(new SystemGroupBackend(new Config()));
+    backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend = new UniversalGroupBackend(backends);
   }
 
@@ -123,7 +123,7 @@
     replay(member, notMember, backend);
 
     backends = new DynamicSet<>();
-    backends.add(backend);
+    backends.add("gerrit", backend);
     backend = new UniversalGroupBackend(backends);
 
     GroupMembership checker = backend.membershipsOf(member);
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
new file mode 100644
index 0000000..f29ff1f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.inject.TypeLiteral;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class AllExternalIdsTest {
+  @Test
+  public void serializeEmptyExternalIds() throws Exception {
+    assertRoundTrip(allExternalIds(), AllExternalIdsProto.getDefaultInstance());
+  }
+
+  @Test
+  public void serializeMultipleExternalIds() throws Exception {
+    Account.Id accountId1 = new Account.Id(1001);
+    Account.Id accountId2 = new Account.Id(1002);
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create("scheme1", "id1", accountId1),
+            ExternalId.create("scheme2", "id2", accountId1),
+            ExternalId.create("scheme2", "id3", accountId2),
+            ExternalId.create("scheme3", "id4", accountId2)),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme1:id1").setAccountId(1001).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme2:id2").setAccountId(1001).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme2:id3").setAccountId(1002).build())
+            .addExternalId(
+                ExternalIdProto.newBuilder().setKey("scheme3:id4").setAccountId(1002).build())
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithEmail() throws Exception {
+    assertRoundTrip(
+        allExternalIds(ExternalId.createEmail(new Account.Id(1001), "foo@example.com")),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("mailto:foo@example.com")
+                    .setAccountId(1001)
+                    .setEmail("foo@example.com"))
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithPassword() throws Exception {
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create("scheme", "id", new Account.Id(1001), null, "hashed password")),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("scheme:id")
+                    .setAccountId(1001)
+                    .setPassword("hashed password"))
+            .build());
+  }
+
+  @Test
+  public void serializeExternalIdWithBlobId() throws Exception {
+    assertRoundTrip(
+        allExternalIds(
+            ExternalId.create(
+                ExternalId.create("scheme", "id", new Account.Id(1001)),
+                ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))),
+        AllExternalIdsProto.newBuilder()
+            .addExternalId(
+                ExternalIdProto.newBuilder()
+                    .setKey("scheme:id")
+                    .setAccountId(1001)
+                    .setBlobId(
+                        byteString(
+                            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+                            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef)))
+            .build());
+  }
+
+  @Test
+  public void allExternalIdsMethods() {
+    assertThatSerializedClass(AllExternalIds.class)
+        .hasAutoValueMethods(
+            ImmutableMap.of(
+                "byAccount",
+                    new TypeLiteral<ImmutableSetMultimap<Account.Id, ExternalId>>() {}.getType(),
+                "byEmail",
+                    new TypeLiteral<ImmutableSetMultimap<String, ExternalId>>() {}.getType()));
+  }
+
+  @Test
+  public void externalIdMethods() {
+    assertThatSerializedClass(ExternalId.class)
+        .hasAutoValueMethods(
+            ImmutableMap.of(
+                "key", ExternalId.Key.class,
+                "accountId", Account.Id.class,
+                "email", String.class,
+                "password", String.class,
+                "blobId", ObjectId.class));
+  }
+
+  private static AllExternalIds allExternalIds(ExternalId... externalIds) {
+    return AllExternalIds.create(Arrays.asList(externalIds));
+  }
+
+  private static void assertRoundTrip(
+      AllExternalIds allExternalIds, AllExternalIdsProto expectedProto) throws Exception {
+    AllExternalIdsProto actualProto =
+        AllExternalIdsProto.parseFrom(Serializer.INSTANCE.serialize(allExternalIds));
+    assertThat(actualProto).ignoringRepeatedFieldOrder().isEqualTo(expectedProto);
+    AllExternalIds actual =
+        Serializer.INSTANCE.deserialize(Serializer.INSTANCE.serialize(allExternalIds));
+    assertThat(actual).isEqualTo(allExternalIds);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 5e93a09..81fd6d7 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -6,8 +6,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.lang.reflect.Type;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index ab88169..4950266 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -5,16 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//lib:guava",
-        "//lib:gwtorm",
         "//lib:junit",
-        "//lib:protobuf",
-        "//lib/auto:auto-value",
-        "//lib/auto:auto-value-annotations",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
-        "//lib/truth:truth-proto-extension",
-        "//proto:cache_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 63ae94b..2ee8e48 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 9bba996..147aeeb 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -19,9 +19,9 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.server.cache.StringSerializer;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -42,8 +42,8 @@
         new SqlStore<>(
             "jdbc:h2:mem:Test_" + id,
             KEY_TYPE,
-            StringSerializer.INSTANCE,
-            StringSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
             version,
             1 << 20,
             null);
@@ -87,9 +87,9 @@
   @Test
   public void stringSerializer() {
     String input = "foo";
-    byte[] serialized = StringSerializer.INSTANCE.serialize(input);
+    byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
     assertThat(serialized).isEqualTo(new byte[] {'f', 'o', 'o'});
-    assertThat(StringSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..35d8527
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,20 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:junit",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index 3186620..7504850 100644
--- a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
index 60bbb16..0b80fc7 100644
--- a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
index 7a7c27c..987a62a 100644
--- a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
index 962b797..c2db808 100644
--- a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index 41d07b9..6596730 100644
--- a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
new file mode 100644
index 0000000..c56f8f8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdCacheSerializerTest {
+  @Test
+  public void serialize() {
+    ObjectId id = ObjectId.fromString("aabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+    byte[] serialized = ObjectIdCacheSerializer.INSTANCE.serialize(id);
+    assertThat(serialized)
+        .isEqualTo(
+            byteArray(
+                0xaa, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
+                0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
+    assertThat(ObjectIdCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(id);
+  }
+
+  @Test
+  public void deserializeInvalid() {
+    assertDeserializeFails(null);
+    assertDeserializeFails(byteArray());
+    assertDeserializeFails(byteArray(0xaa));
+    assertDeserializeFails(
+        byteArray(
+            0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
+            0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa));
+  }
+
+  private void assertDeserializeFails(byte[] bytes) {
+    try {
+      ObjectIdCacheSerializer.INSTANCE.deserialize(bytes);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
similarity index 93%
rename from javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
index 8bf9762..69694fe 100644
--- a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -32,13 +32,13 @@
     ObjectIdConverter idConverter = ObjectIdConverter.create();
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa)))
         .isEqualTo(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb)))
         .isEqualTo(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
@@ -61,14 +61,14 @@
             idConverter.toByteString(
                 ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa));
     assertThat(
             idConverter.toByteString(
                 ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
   }
diff --git a/javatests/com/google/gerrit/server/cache/StringSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
similarity index 69%
rename from javatests/com/google/gerrit/server/cache/StringSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
index 3035338..fa3b7d7 100644
--- a/javatests/com/google/gerrit/server/cache/StringSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
@@ -21,12 +21,13 @@
 import java.nio.charset.StandardCharsets;
 import org.junit.Test;
 
-public class StringSerializerTest {
+public class StringCacheSerializerTest {
   @Test
   public void serialize() {
-    assertThat(StringSerializer.INSTANCE.serialize("")).isEmpty();
-    assertThat(StringSerializer.INSTANCE.serialize("abc")).isEqualTo(new byte[] {'a', 'b', 'c'});
-    assertThat(StringSerializer.INSTANCE.serialize("a\u1234c"))
+    assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.serialize("abc"))
+        .isEqualTo(new byte[] {'a', 'b', 'c'});
+    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c"))
         .isEqualTo(new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'});
   }
 
@@ -34,7 +35,7 @@
   public void serializeInvalidChar() {
     // Can't use UTF-8 for the test, since it can encode all Unicode code points.
     try {
-      StringSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
+      StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
       assert_().fail("expected IllegalStateException");
     } catch (IllegalStateException expected) {
       assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
@@ -43,10 +44,11 @@
 
   @Test
   public void deserialize() {
-    assertThat(StringSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
-    assertThat(StringSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'})).isEqualTo("abc");
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'}))
+        .isEqualTo("abc");
     assertThat(
-            StringSerializer.INSTANCE.deserialize(
+            StringCacheSerializer.INSTANCE.deserialize(
                 new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'}))
         .isEqualTo("a\u1234c");
   }
@@ -54,7 +56,7 @@
   @Test
   public void deserializeInvalidChar() {
     try {
-      StringSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
+      StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
       assert_().fail("expected IllegalStateException");
     } catch (IllegalStateException expected) {
       assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 03e0d4e..b847ed7 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -39,9 +39,9 @@
     assertThat(ChangeKindKeyProto.parseFrom(serialized))
         .isEqualTo(
             ChangeKindKeyProto.newBuilder()
-                .setPrior(bytes(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+                .setPrior(byteString(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
                 .setNext(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setStrategyName("aStrategy")
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
index e91c3b4..dca2dcb 100644
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -27,7 +26,6 @@
 import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -55,9 +53,6 @@
   private RevCommit commit_v1_3;
   private RevCommit commit_v2_5;
 
-  private List<String> expTags = new ArrayList<>();
-  private List<String> expBranches = new ArrayList<>();
-
   private RevWalk revWalk;
 
   @Override
@@ -140,12 +135,8 @@
     IncludedInResolver.Result detail = resolve(commit_v2_5);
 
     // Check that only tags and branches which refer the tip are returned
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags()).containsExactly(TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches()).containsExactly(BRANCH_2_5);
   }
 
   @Test
@@ -154,22 +145,18 @@
     IncludedInResolver.Result detail = resolve(commit_initial);
 
     // Check whether all tags and branches are returned
-    expTags.add(TAG_1_0);
-    expTags.add(TAG_1_0_1);
-    expTags.add(TAG_1_3);
-    expTags.add(TAG_2_0);
-    expTags.add(TAG_2_0_1);
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-
-    expBranches.add(BRANCH_MASTER);
-    expBranches.add(BRANCH_1_0);
-    expBranches.add(BRANCH_1_3);
-    expBranches.add(BRANCH_2_0);
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags())
+        .containsExactly(
+            TAG_1_0,
+            TAG_1_0_1,
+            TAG_1_3,
+            TAG_2_0,
+            TAG_2_0_1,
+            TAG_2_5,
+            TAG_2_5_ANNOTATED,
+            TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches())
+        .containsExactly(BRANCH_MASTER, BRANCH_1_0, BRANCH_1_3, BRANCH_2_0, BRANCH_2_5);
   }
 
   @Test
@@ -178,27 +165,15 @@
     IncludedInResolver.Result detail = resolve(commit_v1_3);
 
     // Check whether all succeeding tags and branches are returned
-    expTags.add(TAG_1_3);
-    expTags.add(TAG_2_5);
-    expTags.add(TAG_2_5_ANNOTATED);
-    expTags.add(TAG_2_5_ANNOTATED_TWICE);
-    assertEquals(expTags, detail.getTags());
-
-    expBranches.add(BRANCH_1_3);
-    expBranches.add(BRANCH_2_5);
-    assertEquals(expBranches, detail.getBranches());
+    assertThat(detail.tags())
+        .containsExactly(TAG_1_3, TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
+    assertThat(detail.branches()).containsExactly(BRANCH_1_3, BRANCH_2_5);
   }
 
   private IncludedInResolver.Result resolve(RevCommit commit) throws Exception {
     return IncludedInResolver.resolve(db, revWalk, commit);
   }
 
-  private void assertEquals(List<String> list1, List<String> list2) {
-    Collections.sort(list1);
-    Collections.sort(list2);
-    Assert.assertEquals(list1, list2);
-  }
-
   private void createAndCheckoutBranch(ObjectId objectId, String branchName) throws IOException {
     String fullBranchName = "refs/heads/" + branchName;
     super.createBranch(objectId, fullBranchName);
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index c8e6f2b..e10a236 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -39,11 +39,11 @@
         .isEqualTo(
             MergeabilityKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setInto(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
index b9bac50..3d3e734 100644
--- a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
 import org.junit.Test;
@@ -54,4 +56,14 @@
     assertThat(deserialized.getProjectName()).isEqualTo(holder.getProjectName());
     TagSetTest.assertEqual(holder.getTagSet(), deserialized.getTagSet());
   }
+
+  @Test
+  public void fields() {
+    assertThatSerializedClass(TagSetHolder.class)
+        .hasFields(
+            ImmutableMap.of(
+                "buildLock", Object.class,
+                "projectName", Project.NameKey.class,
+                "tags", TagSet.class));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 2591c3d..1eebe75 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -17,8 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
@@ -28,11 +30,14 @@
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
 import com.google.gerrit.server.git.TagSet.CachedRef;
 import com.google.gerrit.server.git.TagSet.Tag;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
 import java.util.Arrays;
 import java.util.BitSet;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.junit.Test;
@@ -66,7 +71,7 @@
                     "refs/heads/master",
                     CachedRefProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa))
                         .setFlag(1)
@@ -75,7 +80,7 @@
                     "refs/heads/branch",
                     CachedRefProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb))
                         .setFlag(2)
@@ -83,24 +88,60 @@
                 .addTag(
                     TagProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
-                        .setFlags(bytes(0x2a))
+                        .setFlags(byteString(0x2a))
                         .build())
                 .addTag(
                     TagProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
-                        .setFlags(bytes(0x54))
+                        .setFlags(byteString(0x54))
                         .build())
                 .build());
 
     assertEqual(tagSet, TagSet.fromProto(proto));
   }
 
+  @Test
+  public void tagSetFields() {
+    assertThatSerializedClass(TagSet.class)
+        .hasFields(
+            ImmutableMap.of(
+                "projectName", Project.NameKey.class,
+                "refs", new TypeLiteral<Map<String, CachedRef>>() {}.getType(),
+                "tags", new TypeLiteral<ObjectIdOwnerMap<Tag>>() {}.getType()));
+  }
+
+  @Test
+  public void cachedRefFields() {
+    assertThatSerializedClass(CachedRef.class)
+        .extendsClass(new TypeLiteral<AtomicReference<ObjectId>>() {}.getType());
+    assertThatSerializedClass(CachedRef.class)
+        .hasFields(
+            ImmutableMap.of(
+                "flag", int.class, "value", AtomicReference.class.getTypeParameters()[0]));
+  }
+
+  @Test
+  public void tagFields() {
+    assertThatSerializedClass(Tag.class).extendsClass(ObjectIdOwnerMap.Entry.class);
+    assertThatSerializedClass(Tag.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("refFlags", BitSet.class)
+                .put("next", ObjectIdOwnerMap.Entry.class)
+                .put("w1", int.class)
+                .put("w2", int.class)
+                .put("w3", int.class)
+                .put("w4", int.class)
+                .put("w5", int.class)
+                .build());
+  }
+
   // TODO(dborowitz): Find some more common place to put this method, which requires access to
   // package-private TagSet details.
   static void assertEqual(@Nullable TagSet a, @Nullable TagSet b) {
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6090a78..56e53b9 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -203,7 +203,7 @@
 
   private MyMetaData load(String ref, int expectedValue) throws Exception {
     MyMetaData d = new MyMetaData(ref);
-    d.load(repo);
+    d.load(project, repo);
     assertThat(d.getValue()).isEqualTo(expectedValue);
     return d;
   }
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 2aa6035..943f784 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -140,7 +140,7 @@
           @Override
           public String getName() {
             try {
-              return GroupConfig.loadForGroup(allUsersRepo, uuid)
+              return GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid)
                   .getLoadedGroup()
                   .map(InternalGroup::getName)
                   .orElse("Group " + uuid);
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 4e93aee..309d710 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -37,7 +37,7 @@
 
   @Before
   public void setUp() throws Exception {
-    auditLogReader = new AuditLogReader(SERVER_ID);
+    auditLogReader = new AuditLogReader(SERVER_ID, allUsersName);
   }
 
   @Test
@@ -250,7 +250,8 @@
                 .setMemberModification(members -> ImmutableSet.of(authorId))
                 .build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    GroupConfig groupConfig =
+        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
 
     groupConfig.commit(createMetaDataUpdate(authorIdent));
@@ -261,7 +262,7 @@
 
   private void updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
     groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
     groupConfig.commit(createMetaDataUpdate(userIdent));
   }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index d03a38b..dec1d63 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -65,6 +65,7 @@
 
   @Rule public ExpectedException expectedException = ExpectedException.none();
 
+  private Project.NameKey projectName;
   private Repository repository;
   private TestRepository<?> testRepository;
   private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
@@ -76,6 +77,7 @@
 
   @Before
   public void setUp() throws Exception {
+    projectName = new Project.NameKey("Test Repository");
     repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
     testRepository = new TestRepository<>(repository);
   }
@@ -117,7 +119,7 @@
   public void nameOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -130,7 +132,7 @@
   public void nameOfNewGroupMustNotBeNull() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -152,7 +154,7 @@
   public void idOfNewGroupMustNotBeNegative() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       expectedException.expectCause(instanceOf(ConfigInvalidException.class));
@@ -230,7 +232,7 @@
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
@@ -245,7 +247,7 @@
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
@@ -362,7 +364,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -372,7 +374,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -398,7 +400,7 @@
 
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage("Owner UUID of the group " + groupUuid);
-    GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig.loadForGroup(projectName, repository, groupUuid);
   }
 
   @Test
@@ -546,7 +548,7 @@
   public void nameCannotBeUpdatedToNull() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -562,7 +564,7 @@
   public void nameCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -579,7 +581,7 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
     InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(emptyName).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -629,7 +631,7 @@
   public void ownerGroupUuidCannotBeUpdatedToNull() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -645,7 +647,7 @@
   public void ownerGroupUuidCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -874,7 +876,7 @@
   public void groupConfigMayBeReusedForFurtherUpdates() throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).setId(groupId).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
 
     AccountGroup.NameKey name = new AccountGroup.NameKey("Robots");
@@ -1054,7 +1056,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1072,7 +1074,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("A test group").build();
 
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1138,7 +1140,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent committerIdent =
@@ -1171,7 +1173,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent authorIdent =
@@ -1230,7 +1232,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent committerIdent =
@@ -1258,7 +1260,7 @@
             .setName(new AccountGroup.NameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     PersonIdent authorIdent =
@@ -1299,7 +1301,8 @@
     updateGroup(groupUuid, groupUpdate2);
 
     GroupConfig groupConfig =
-        GroupConfig.loadForGroupSnapshot(repository, groupUuid, commitAfterUpdate1.copy());
+        GroupConfig.loadForGroupSnapshot(
+            projectName, repository, groupUuid, commitAfterUpdate1.copy());
     Optional<InternalGroup> group = groupConfig.getLoadedGroup();
     assertThatGroup(group).value().nameKey().isEqualTo(firstName);
     assertThatGroup(group).value().refState().isEqualTo(commitAfterUpdate1.copy());
@@ -1344,7 +1347,7 @@
             .setMemberModification(members -> ImmutableSet.of(account13.getId(), account7.getId()))
             .build();
 
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1369,7 +1372,7 @@
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
 
@@ -1584,14 +1587,14 @@
 
   private Optional<InternalGroup> createGroup(InternalGroupCreation groupCreation)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
   private Optional<InternalGroup> createGroup(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate) throws Exception {
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
@@ -1605,14 +1608,14 @@
   private Optional<InternalGroup> updateGroup(
       AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter)
       throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
   private Optional<InternalGroup> loadGroup(AccountGroup.UUID uuid) throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, uuid);
+    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
     return groupConfig.getLoadedGroup();
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3616e0e..a70df6b 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -84,12 +85,14 @@
   private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
 
   private AtomicInteger idCounter;
+  private AllUsersName allUsersName;
   private Repository repo;
 
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
     idCounter = new AtomicInteger();
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repo = new InMemoryRepository(new DfsRepositoryDescription(AllUsersNameProvider.DEFAULT));
   }
 
@@ -110,13 +113,13 @@
   @Test
   public void uuidOfNewGroupMustNotBeNull() throws Exception {
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, null, groupName);
+    GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName);
   }
 
   @Test
   public void nameOfNewGroupMustNotBeNull() throws Exception {
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(repo, groupUuid, null);
+    GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null);
   }
 
   @Test
@@ -135,7 +138,7 @@
     AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("AnotherGroup");
     expectedException.expect(OrmDuplicateKeyException.class);
     expectedException.expectMessage(groupName.get());
-    GroupNameNotes.forNewGroup(repo, anotherGroupUuid, groupName);
+    GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName);
   }
 
   @Test
@@ -179,7 +182,7 @@
     createGroup(groupUuid, groupName);
 
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, groupName, null);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null);
   }
 
   @Test
@@ -188,7 +191,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, groupUuid, null, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName);
   }
 
   @Test
@@ -199,7 +202,7 @@
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage(anotherOldName.get());
-    GroupNameNotes.forRename(repo, groupUuid, anotherOldName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, anotherOldName, anotherName);
   }
 
   @Test
@@ -211,7 +214,7 @@
 
     expectedException.expect(OrmDuplicateKeyException.class);
     expectedException.expectMessage(anotherGroupName.get());
-    GroupNameNotes.forRename(repo, groupUuid, groupName, anotherGroupName);
+    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherGroupName);
   }
 
   @Test
@@ -220,7 +223,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(NullPointerException.class);
-    GroupNameNotes.forRename(repo, null, groupName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName);
   }
 
   @Test
@@ -231,7 +234,7 @@
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     expectedException.expect(ConfigInvalidException.class);
     expectedException.expectMessage(groupUuid.get());
-    GroupNameNotes.forRename(repo, anotherGroupUuid, groupName, anotherName);
+    GroupNameNotes.forRename(allUsersName, repo, anotherGroupUuid, groupName, anotherName);
   }
 
   @Test
@@ -287,7 +290,8 @@
 
   @Test
   public void newCommitIsNotCreatedWhenCommittingGroupCreationTwice() throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forNewGroup(repo, groupUuid, groupName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, groupName);
 
     commit(groupNameNotes);
     ImmutableList<CommitInfo> commitsAfterFirstCommit = log();
@@ -303,7 +307,7 @@
 
     AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
     GroupNameNotes groupNameNotes =
-        GroupNameNotes.forRename(repo, groupUuid, groupName, anotherName);
+        GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherName);
 
     commit(groupNameNotes);
     ImmutableList<CommitInfo> commitsAfterFirstCommit = log();
@@ -504,14 +508,16 @@
 
   private void createGroup(AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
       throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forNewGroup(repo, groupUuid, groupName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, groupName);
     commit(groupNameNotes);
   }
 
   private void renameGroup(
       AccountGroup.UUID groupUuid, AccountGroup.NameKey oldName, AccountGroup.NameKey newName)
       throws Exception {
-    GroupNameNotes groupNameNotes = GroupNameNotes.forRename(repo, groupUuid, oldName, newName);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.forRename(allUsersName, repo, groupUuid, oldName, newName);
     commit(groupNameNotes);
   }
 
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
new file mode 100644
index 0000000..5117c01
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.truth.Expect;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class LoggingContextAwareExecutorServiceTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Test
+  public void loggingContextPropagationToBackgroundThread() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+
+      ExecutorService executor =
+          new LoggingContextAwareExecutorService(Executors.newFixedThreadPool(1));
+      executor
+          .submit(
+              () -> {
+                // Verify that the tags and force logging flag have been propagated to the new
+                // thread.
+                SortedMap<String, SortedSet<Object>> threadTagMap =
+                    LoggingContext.getInstance().getTags().asMap();
+                expect.that(threadTagMap.keySet()).containsExactly("foo");
+                expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                expect
+                    .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+                    .isTrue();
+              })
+          .get();
+
+      // Verify that tags and force logging flag in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+      assertForceLogging(true);
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    assertForceLogging(false);
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
new file mode 100644
index 0000000..4fadbb4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MutableTagsTest {
+  private MutableTags tags;
+
+  @Before
+  public void setup() {
+    tags = new MutableTags();
+  }
+
+  @Test
+  public void addTag() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void addTagsWithDifferentName() {
+    assertThat(tags.add("name1", "value1")).isTrue();
+    assertThat(tags.add("name2", "value2")).isTrue();
+    assertTags(
+        ImmutableMap.of("name1", ImmutableSet.of("value1"), "name2", ImmutableSet.of("value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameButDifferentValues() {
+    assertThat(tags.add("name", "value1")).isTrue();
+    assertThat(tags.add("name", "value2")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value1", "value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameAndSameValue() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertThat(tags.add("name", "value")).isFalse();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void getEmptyTags() {
+    assertThat(tags.getTags().isEmpty()).isTrue();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void isEmpty() {
+    assertThat(tags.isEmpty()).isTrue();
+
+    tags.add("foo", "bar");
+    assertThat(tags.isEmpty()).isFalse();
+
+    tags.remove("foo", "bar");
+    assertThat(tags.isEmpty()).isTrue();
+  }
+
+  @Test
+  public void removeTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.remove("name2", "value");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value1", "value2")));
+
+    tags.remove("name1", "value1");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value2")));
+
+    tags.remove("name1", "value2");
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void removeNonExistingTag() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("foo", "bar");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("name", "foo");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void setTags() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertTags(
+        ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+  }
+
+  @Test
+  public void asMap() {
+    tags.add("name", "value");
+    assertThat(tags.asMap()).containsExactlyEntriesIn(ImmutableSetMultimap.of("name", "value"));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertThat(tags.asMap())
+        .containsExactlyEntriesIn(
+            ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+  }
+
+  @Test
+  public void clearTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.clear();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.add(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.add("foo", null));
+  }
+
+  @Test
+  public void removeInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.remove(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.remove("foo", null));
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertNullPointerException(String expectedMessage, Runnable r) {
+    try {
+      r.run();
+      assert_().fail("expected NullPointerException");
+    } catch (NullPointerException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
new file mode 100644
index 0000000..044d237
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -0,0 +1,264 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.After;
+import org.junit.Test;
+
+public class TraceContextTest {
+  @After
+  public void cleanup() {
+    LoggingContext.getInstance().clearTags();
+    LoggingContext.getInstance().forceLogging(false);
+  }
+
+  @Test
+  public void openContext() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContexts() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("abc", "xyz")) {
+        assertTags(ImmutableMap.of("abc", ImmutableSet.of("xyz"), "foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagName() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "baz")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar", "baz")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagNameAndValue() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "bar")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openContextWithRequestId() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag(RequestId.Type.RECEIVE_ID, "foo")) {
+      assertTags(ImmutableMap.of("RECEIVE_ID", ImmutableSet.of("foo")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addTag() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      traceContext.addTag("foo", "baz");
+      traceContext.addTag("bar", "baz");
+      assertTags(
+          ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openContextWithForceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging()) {
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void openNestedContextsWithForceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open().forceLogging()) {
+      assertForceLogging(true);
+
+      try (TraceContext traceContext2 = TraceContext.open()) {
+        // force logging is still enabled since outer trace context forced logging
+        assertForceLogging(true);
+
+        try (TraceContext traceContext3 = TraceContext.open().forceLogging()) {
+          assertForceLogging(true);
+        }
+
+        assertForceLogging(true);
+      }
+
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void forceLogging() {
+    assertForceLogging(false);
+    try (TraceContext traceContext = TraceContext.open()) {
+      assertForceLogging(false);
+
+      traceContext.forceLogging();
+      assertForceLogging(true);
+
+      traceContext.forceLogging();
+      assertForceLogging(true);
+    }
+    assertForceLogging(false);
+  }
+
+  @Test
+  public void newTrace() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(true, null, traceIdConsumer)) {
+      assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().getTagsAsMap().keySet())
+          .containsExactly(RequestId.Type.TRACE_ID.name());
+    }
+    assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+    assertThat(traceIdConsumer.traceId).isNotNull();
+  }
+
+  @Test
+  public void newTraceWithProvidedTraceId() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    String traceId = "foo";
+    try (TraceContext traceContext = TraceContext.newTrace(true, traceId, traceIdConsumer)) {
+      assertForceLogging(true);
+      assertTags(ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(traceId)));
+    }
+    assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+    assertThat(traceIdConsumer.traceId).isEqualTo(traceId);
+  }
+
+  @Test
+  public void newTraceDisabled() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(false, null, traceIdConsumer)) {
+      assertForceLogging(false);
+      assertTags(ImmutableMap.of());
+    }
+    assertThat(traceIdConsumer.tagName).isNull();
+    assertThat(traceIdConsumer.traceId).isNull();
+  }
+
+  @Test
+  public void newTraceDisabledWithProvidedTraceId() {
+    TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+    try (TraceContext traceContext = TraceContext.newTrace(false, "foo", traceIdConsumer)) {
+      assertForceLogging(false);
+      assertTags(ImmutableMap.of());
+    }
+    assertThat(traceIdConsumer.tagName).isNull();
+    assertThat(traceIdConsumer.traceId).isNull();
+  }
+
+  @Test
+  public void onlyOneTraceId() {
+    TestTraceIdConsumer traceIdConsumer1 = new TestTraceIdConsumer();
+    try (TraceContext traceContext1 = TraceContext.newTrace(true, null, traceIdConsumer1)) {
+      String expectedTraceId = traceIdConsumer1.traceId;
+      assertThat(expectedTraceId).isNotNull();
+
+      TestTraceIdConsumer traceIdConsumer2 = new TestTraceIdConsumer();
+      try (TraceContext traceContext2 = TraceContext.newTrace(true, null, traceIdConsumer2)) {
+        assertForceLogging(true);
+        assertTags(
+            ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(expectedTraceId)));
+      }
+      assertThat(traceIdConsumer2.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+      assertThat(traceIdConsumer2.traceId).isEqualTo(expectedTraceId);
+    }
+  }
+
+  @Test
+  public void multipleTraceIdsIfTraceIdProvided() {
+    String traceId1 = "foo";
+    try (TraceContext traceContext1 =
+        TraceContext.newTrace(true, traceId1, (tagName, traceId) -> {})) {
+      TestTraceIdConsumer traceIdConsumer = new TestTraceIdConsumer();
+      String traceId2 = "bar";
+      try (TraceContext traceContext2 = TraceContext.newTrace(true, traceId2, traceIdConsumer)) {
+        assertForceLogging(true);
+        assertTags(
+            ImmutableMap.of(RequestId.Type.TRACE_ID.name(), ImmutableSet.of(traceId1, traceId2)));
+      }
+      assertThat(traceIdConsumer.tagName).isEqualTo(RequestId.Type.TRACE_ID.name());
+      assertThat(traceIdConsumer.traceId).isEqualTo(traceId2);
+    }
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap =
+        LoggingContext.getInstance().getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertForceLogging(boolean expected) {
+    assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
+        .isEqualTo(expected);
+  }
+
+  private static class TestTraceIdConsumer implements TraceIdConsumer {
+    String tagName;
+    String traceId;
+
+    @Override
+    public void accept(String tagName, String traceId) {
+      this.tagName = tagName;
+      this.traceId = traceId;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index 5a7d812..7b140b7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -41,7 +41,7 @@
                 .setProject("project")
                 .setChangeId(1234)
                 .setId(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 574f6ac..7b41ba3 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableList;
@@ -42,12 +42,12 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
 import com.google.gwtorm.client.KeyUtil;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 852c8bc..8cd2753 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -51,8 +51,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
@@ -426,7 +426,7 @@
   @Test
   public void approvalsPostSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -461,7 +461,7 @@
   @Test
   public void approvalsDuringSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -598,7 +598,7 @@
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
@@ -640,7 +640,7 @@
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
     update.merge(
@@ -941,7 +941,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        RequestId.forChange(c),
+        submissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -3141,4 +3141,8 @@
     update.commit();
     return tr.parseBody(commit);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 963546f..8daf67f 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestChanges;
 import java.util.Date;
@@ -151,7 +151,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId,
         ImmutableList.of(
@@ -220,7 +220,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
@@ -424,4 +424,8 @@
     RevCommit commit = parseCommit(commitId);
     assertThat(commit.getFullMessage()).isEqualTo(expected);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 7890de8..39896ed 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -204,6 +205,7 @@
   @Inject private ThreadLocalRequestContext requestContext;
   @Inject private DefaultRefFilter.Factory refFilterFactory;
   @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject private TransferConfig transferConfig;
 
   @Before
   public void setUp() throws Exception {
@@ -971,6 +973,7 @@
             repoManager,
             commentLinks,
             capabilityCollectionFactory,
+            transferConfig,
             pc));
     return repo;
   }
@@ -998,7 +1001,7 @@
     return all.get(local.getProject().getNameKey());
   }
 
-  private class MockUser extends CurrentUser {
+  private static class MockUser extends CurrentUser {
     @Nullable private final String username;
     private final GroupMembership groups;
 
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 3d9d661..9d01405 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -579,7 +579,7 @@
       PersonIdent ident = serverIdent.get();
       md.getCommitBuilder().setAuthor(ident);
       md.getCommitBuilder().setCommitter(ident);
-      new AccountConfig(accountId, repo)
+      new AccountConfig(accountId, allUsers, repo)
           .load()
           .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
           .commit(md);
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index b87bbf7..de2acf0 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -18,7 +18,7 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -68,11 +68,11 @@
         .isEqualTo(
             ConflictKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setOtherCommit(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 42452df..8f4c90d 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,9 +7,11 @@
     resources = ["//prologtests:gerrit_common_test"],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
new file mode 100644
index 0000000..27f4423
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class IgnoreSelfApprovalRuleTest {
+  private static final Change.Id CHANGE_ID = new Change.Id(100);
+  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final Account.Id USER1 = makeAccount(100001);
+
+  @Test
+  public void filtersByLabel() {
+    LabelType codeReview = makeLabel("Code-Review");
+    PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
+    PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterApprovalsByLabel(
+            ImmutableList.of(approvalVerified, approvalCr), VERIFIED);
+
+    assertThat(filteredApprovals).containsExactly(approvalVerified);
+  }
+
+  @Test
+  public void filtersVotesFromUser() {
+    PatchSetApproval approvalM2 = makeApproval(VERIFIED.getLabelId(), USER1, -2);
+    PatchSetApproval approvalM1 = makeApproval(VERIFIED.getLabelId(), USER1, -1);
+
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(
+            approvalM2,
+            approvalM1,
+            makeApproval(VERIFIED.getLabelId(), USER1, 0),
+            makeApproval(VERIFIED.getLabelId(), USER1, +1),
+            makeApproval(VERIFIED.getLabelId(), USER1, +2));
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterOutPositiveApprovalsOfUser(approvals, USER1);
+
+    assertThat(filteredApprovals).containsExactly(approvalM1, approvalM2);
+  }
+
+  private static LabelType makeLabel(String labelName) {
+    List<LabelValue> values = new ArrayList<>();
+    // The label text is irrelevant here, only the numerical value is used
+    values.add(new LabelValue((short) -2, "-2"));
+    values.add(new LabelValue((short) -1, "-1"));
+    values.add(new LabelValue((short) 0, "No vote."));
+    values.add(new LabelValue((short) 1, "+1"));
+    values.add(new LabelValue((short) 2, "+2"));
+    return new LabelType(labelName, values);
+  }
+
+  private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
+    PatchSetApproval.Key key = makeKey(PS_ID, accountId, labelId);
+    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
+  }
+
+  private static PatchSetApproval.Key makeKey(
+      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
+    return new PatchSetApproval.Key(psId, accountId, labelId);
+  }
+
+  private static Account.Id makeAccount(int account) {
+    return new Account.Id(account);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
index ead824f..c76a246 100644
--- a/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
+++ b/javatests/com/google/gerrit/server/schema/GroupRebuilderTest.java
@@ -66,6 +66,7 @@
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
 
   private AtomicInteger idCounter;
+  private AllUsersName allUsersName;
   private Repository repo;
   private GroupRebuilder rebuilder;
   private GroupBundle.Factory bundleFactory;
@@ -74,7 +75,7 @@
   public void setUp() throws Exception {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
     idCounter = new AtomicInteger();
-    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repo = new InMemoryRepositoryManager().createRepository(allUsersName);
     rebuilder =
         new GroupRebuilder(
@@ -83,7 +84,7 @@
             // Note that the expected name/email values in tests are not necessarily realistic,
             // since they use these trivial name/email functions.
             getAuditLogFormatter());
-    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID));
+    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID, allUsersName));
   }
 
   @After
@@ -606,7 +607,7 @@
   }
 
   private GroupBundle reload(AccountGroup g) throws Exception {
-    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
+    return bundleFactory.fromNoteDb(allUsersName, repo, g.getGroupUUID());
   }
 
   private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
index 7f8b6f3..d3f69982 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
@@ -36,6 +36,7 @@
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
@@ -108,15 +109,22 @@
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
     assertThat(codeReview.isCopyMinScore()).isTrue();
-    assertValueRange(codeReview, 2, 1, 0, -1, -2);
+    assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
   private void assertValueRange(LabelType label, Integer... range) {
-    assertThat(label.getValuesAsList()).containsExactlyElementsIn(Arrays.asList(range)).inOrder();
-    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
-    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
+    List<Integer> rangeList = Arrays.asList(range);
+    assertThat(rangeList).isNotEmpty();
+    assertThat(rangeList).isStrictlyOrdered();
+
+    assertThat(label.getValues().stream().map(v -> (int) v.getValue()))
+        .containsExactlyElementsIn(rangeList)
+        .inOrder();
+    assertThat(label.getMax().getValue()).isEqualTo(Collections.max(rangeList));
+    assertThat(label.getMin().getValue()).isEqualTo(Collections.min(rangeList));
     for (LabelValue v : label.getValues()) {
-      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
+      assertThat(v.getText()).isNotNull();
+      assertThat(v.getText()).isNotEmpty();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
index a01d611..0080f3f 100644
--- a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
+++ b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
@@ -189,7 +189,7 @@
       Supplier<VersionedAccountPreferences> prefsSupplier) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       VersionedAccountPreferences prefs = prefsSupplier.get();
-      prefs.load(repo);
+      prefs.load(allUsersName, repo);
       Config cfg = prefs.getConfig();
       return cfg.getSubsections(MY)
           .stream()
diff --git a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
index 6020325..75f9307 100644
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
+++ b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
@@ -586,7 +586,7 @@
     AccountGroup group = createInReviewDb("group");
 
     TestGroupBackend testGroupBackend = new TestGroupBackend();
-    backends.add(testGroupBackend);
+    backends.add("gerrit", testGroupBackend);
     AccountGroup.UUID subgroupUuid = testGroupBackend.create("test").getGroupUUID();
     assertThat(groupBackend.handles(subgroupUuid)).isTrue();
     addSubgroupsInReviewDb(group.getId(), subgroupUuid);
@@ -916,7 +916,7 @@
 
   private GroupBundle readGroupBundleFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
     try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return groupBundleFactory.fromNoteDb(allUsersRepo, groupUuid);
+      return groupBundleFactory.fromNoteDb(allUsersName, allUsersRepo, groupUuid);
     }
   }
 
@@ -985,7 +985,7 @@
 
   private Optional<InternalGroup> getGroupFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
     try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupConfig.loadForGroup(allUsersRepo, groupUuid).getLoadedGroup();
+      return GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid).getLoadedGroup();
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/util/ParboiledTest.java b/javatests/com/google/gerrit/server/util/ParboiledTest.java
deleted file mode 100644
index 3bcfb56..0000000
--- a/javatests/com/google/gerrit/server/util/ParboiledTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.parboiled.BaseParser;
-import org.parboiled.Parboiled;
-import org.parboiled.Rule;
-import org.parboiled.annotations.BuildParseTree;
-import org.parboiled.parserunners.ReportingParseRunner;
-import org.parboiled.support.ParseTreeUtils;
-import org.parboiled.support.ParsingResult;
-
-public class ParboiledTest {
-
-  private static final String EXPECTED =
-      "[Expression] '42'\n"
-          + "  [Term] '42'\n"
-          + "    [Factor] '42'\n"
-          + "      [Number] '42'\n"
-          + "        [0..9] '4'\n"
-          + "        [0..9] '2'\n"
-          + "    [zeroOrMore]\n"
-          + "  [zeroOrMore]\n";
-
-  private CalculatorParser parser;
-
-  @Before
-  public void setUp() {
-    parser = Parboiled.createParser(CalculatorParser.class);
-  }
-
-  @Test
-  public void test() {
-    ParsingResult<String> result = new ReportingParseRunner<String>(parser.Expression()).run("42");
-    assertThat(result.isSuccess()).isTrue();
-    // next test is optional; we could stop here.
-    assertThat(ParseTreeUtils.printNodeTree(result)).isEqualTo(EXPECTED);
-  }
-
-  @BuildParseTree
-  static class CalculatorParser extends BaseParser<Object> {
-    Rule Expression() {
-      return sequence(Term(), zeroOrMore(anyOf("+-"), Term()));
-    }
-
-    Rule Term() {
-      return sequence(Factor(), zeroOrMore(anyOf("*/"), Factor()));
-    }
-
-    Rule Factor() {
-      return firstOf(Number(), sequence('(', Expression(), ')'));
-    }
-
-    Rule Number() {
-      return oneOrMore(charRange('0', '9'));
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/util/git/BUILD b/javatests/com/google/gerrit/server/util/git/BUILD
index 928705c..61a776fa 100644
--- a/javatests/com/google/gerrit/server/util/git/BUILD
+++ b/javatests/com/google/gerrit/server/util/git/BUILD
@@ -12,7 +12,6 @@
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/truth",
         "//java/org/eclipse/jgit:server",
-        "//lib:grappa",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index 2b1a07e..f6b3e30 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -25,6 +25,7 @@
 import com.google.common.net.HttpHeaders;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.nio.charset.Charset;
 import java.util.Collection;
@@ -106,7 +107,7 @@
   public synchronized PrintWriter getWriter() {
     checkState(outputStream == null, "getOutputStream() already called");
     if (writer == null) {
-      writer = new PrintWriter(actualBody);
+      writer = new PrintWriter(new OutputStreamWriter(actualBody, UTF_8));
     }
     return writer;
   }
diff --git a/lib/BUILD b/lib/BUILD
index 38b0b80..e5034c9 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -123,32 +123,257 @@
 )
 
 java_library(
-    name = "pegdown",
-    data = ["//lib:LICENSE-Apache2.0"],
+    name = "flexmark",
+    data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@pegdown//jar"],
-    runtime_deps = [":grappa"],
-)
-
-java_library(
-    name = "grappa",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@grappa//jar"],
+    exports = ["@flexmark//jar"],
     runtime_deps = [
-        ":jitescript",
-        "//lib/ow2:ow2-asm",
-        "//lib/ow2:ow2-asm-analysis",
-        "//lib/ow2:ow2-asm-tree",
-        "//lib/ow2:ow2-asm-util",
+        ":flexmark-ext-abbreviation",
     ],
 )
 
 java_library(
-    name = "jitescript",
-    data = ["//lib:LICENSE-Apache2.0"],
+    name = "flexmark-ext-abbreviation",
+    data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@jitescript//jar"],
+    exports = ["@flexmark-ext-abbreviation//jar"],
+    runtime_deps = [
+        ":flexmark-ext-anchorlink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-anchorlink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-anchorlink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-autolink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-autolink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-autolink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-definition",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-definition",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-definition//jar"],
+    runtime_deps = [
+        ":flexmark-ext-emoji",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-emoji",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-emoji//jar"],
+    runtime_deps = [
+        ":flexmark-ext-escaped-character",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-escaped-character",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-escaped-character//jar"],
+    runtime_deps = [
+        ":flexmark-ext-footnotes",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-footnotes",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-footnotes//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-issues",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-issues",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-issues//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-strikethrough",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-strikethrough",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-strikethrough//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-tables",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-tables",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-tables//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-tasklist",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-tasklist",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-tasklist//jar"],
+    runtime_deps = [
+        ":flexmark-ext-gfm-users",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-gfm-users",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-gfm-users//jar"],
+    runtime_deps = [
+        ":flexmark-ext-ins",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-ins",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-ins//jar"],
+    runtime_deps = [
+        ":flexmark-ext-jekyll-front-matter",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-jekyll-front-matter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-jekyll-front-matter//jar"],
+    runtime_deps = [
+        ":flexmark-ext-superscript",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-superscript",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-superscript//jar"],
+    runtime_deps = [
+        ":flexmark-ext-tables",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-tables",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-tables//jar"],
+    runtime_deps = [
+        ":flexmark-ext-toc",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-toc",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-toc//jar"],
+    runtime_deps = [
+        ":flexmark-ext-typographic",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-typographic",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-typographic//jar"],
+    runtime_deps = [
+        ":flexmark-ext-wikilink",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-wikilink",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-wikilink//jar"],
+    runtime_deps = [
+        ":flexmark-ext-yaml-front-matter",
+    ],
+)
+
+java_library(
+    name = "flexmark-ext-yaml-front-matter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-ext-yaml-front-matter//jar"],
+    runtime_deps = [
+        ":flexmark-formatter",
+    ],
+)
+
+java_library(
+    name = "flexmark-formatter",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-formatter//jar"],
+    runtime_deps = [
+        ":flexmark-html-parser",
+    ],
+)
+
+java_library(
+    name = "flexmark-html-parser",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-html-parser//jar"],
+    runtime_deps = [
+        ":flexmark-profile-pegdown",
+    ],
+)
+
+java_library(
+    name = "flexmark-profile-pegdown",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-profile-pegdown//jar"],
+    runtime_deps = [
+        ":flexmark-util",
+    ],
+)
+
+java_library(
+    name = "flexmark-util",
+    data = ["//lib:LICENSE-flexmark"],
+    visibility = ["//visibility:public"],
+    exports = ["@flexmark-util//jar"],
+)
+
+java_library(
+    name = "autolink",
+    data = ["//lib:LICENSE-autolink"],
+    visibility = ["//visibility:public"],
+    exports = ["@autolink//jar"],
 )
 
 java_library(
diff --git a/lib/LICENSE-Apache1.1 b/lib/LICENSE-Apache1.1
deleted file mode 100644
index 8eda4fc..0000000
--- a/lib/LICENSE-Apache1.1
+++ /dev/null
@@ -1,51 +0,0 @@
-The Apache Software License, Version 1.1
-
-Copyright (c) 2000-2002 The Apache Software Foundation.  All rights
-reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
-
-1. Redistributions of source code must retain the above copyright
-   notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
-   notice, this list of conditions and the following disclaimer in
-   the documentation and/or other materials provided with the
-   distribution.
-
-3. The end-user documentation included with the redistribution,
-   if any, must include the following acknowledgment:
-      "This product includes software developed by the
-       Apache Software Foundation (http://www.apache.org/)."
-   Alternately, this acknowledgment may appear in the software itself,
-   if and wherever such third-party acknowledgments normally appear.
-
-4. The names "Apache" and "Apache Software Foundation", "Jakarta-Oro"
-   must not be used to endorse or promote products derived from this
-   software without prior written permission. For written
-   permission, please contact apache@apache.org.
-
-5. Products derived from this software may not be called "Apache"
-   or "Jakarta-Oro", nor may "Apache" or "Jakarta-Oro" appear in their
-   name, without prior written permission of the Apache Software Foundation.
-
-THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED
-WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED.  IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR
-ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
-USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
-ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
-OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.
-====================================================================
-
-This software consists of voluntary contributions made by many
-individuals on behalf of the Apache Software Foundation.  For more
-information on the Apache Software Foundation, please see
-<http://www.apache.org/>.
diff --git a/lib/LICENSE-autolink b/lib/LICENSE-autolink
new file mode 100644
index 0000000..565820a
--- /dev/null
+++ b/lib/LICENSE-autolink
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Robin Stocker
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/LICENSE-flexmark b/lib/LICENSE-flexmark
new file mode 100644
index 0000000..c5e6ce0
--- /dev/null
+++ b/lib/LICENSE-flexmark
@@ -0,0 +1,26 @@
+Copyright (c) 2015-2016, Atlassian Pty Ltd
+All rights reserved.
+
+Copyright (c) 2016, Vladimir Schneider,
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index ba34750..7191901 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,6 +1,6 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_CENTRAL", "MAVEN_LOCAL", "maven_jar")
 
-_JGIT_VERS = "5.0.2.201807311906-r"
+_JGIT_VERS = "5.0.3.201809091024-r"
 
 _DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
@@ -18,36 +18,50 @@
             name = "jgit",
             path = LOCAL_JGIT_REPO,
         )
+        jgit_maven_repos_dev()
     else:
         jgit_maven_repos()
 
+def jgit_maven_repos_dev():
+    # Transitive dependencies from JGit's WORKSPACE.
+    maven_jar(
+        name = "hamcrest-library",
+        artifact = "org.hamcrest:hamcrest-library:1.3",
+        sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
+    )
+    maven_jar(
+        name = "jzlib",
+        artifact = "com.jcraft:jzlib:1.1.1",
+        sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
+    )
+
 def jgit_maven_repos():
     maven_jar(
         name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "a81d7c8d153a8a744b6be1d9c6d698270beec1c0",
-        src_sha1 = "c89f8f38cebaf75d13f9b2f7a1da71206d8c38f7",
+        sha1 = "0afec2df3ff8835bc4d5c279d14fad0daae6dd93",
+        src_sha1 = "e2c978064e2a46b260bbda0d8c393ed741046420",
         unsign = True,
     )
     maven_jar(
         name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "ab3d0c85bc2008da513c1127ab4acf3df8ef414e",
+        sha1 = "8fb0f9b6c38ac6fce60f2ead740e03dd79c3c288",
         unsign = True,
     )
     maven_jar(
         name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "ba6e0aaf3f733f2f460e227145526e1737ca160f",
+        sha1 = "72a157ce261f3eb938d9e0ee83d7c9700aa7d736",
     )
     maven_jar(
         name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "fe28963520e19c918eb26747e678ec9772ba800f",
+        sha1 = "eb430358d96dedd923e4075cd54a7db4cab51ca2",
         unsign = True,
     )
 
diff --git a/plugins/BUILD b/plugins/BUILD
index ef18e01..a7622b2 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -27,17 +27,19 @@
 ]
 
 EXPORTS = [
+    "//antlr3:query_parser",
     "//java/com/google/gerrit/common:annotations",
     "//java/com/google/gerrit/common:server",
     "//java/com/google/gerrit/extensions:api",
     "//java/com/google/gerrit/index",
     "//java/com/google/gerrit/index:query_exception",
-    "//java/com/google/gerrit/index:query_parser",
     "//java/com/google/gerrit/lifecycle",
     "//java/com/google/gerrit/metrics",
     "//java/com/google/gerrit/metrics/dropwizard",
     "//java/com/google/gerrit/reviewdb:server",
     "//java/com/google/gerrit/server/audit",
+    "//java/com/google/gerrit/server/logging",
+    "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/util/http",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
@@ -100,13 +102,13 @@
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [
+        "//antlr3:libquery_parser-src.jar",
         "//java/com/google/gerrit/common:libannotations-src.jar",
         "//java/com/google/gerrit/common:libserver-src.jar",
         "//java/com/google/gerrit/extensions:libapi-src.jar",
         "//java/com/google/gerrit/httpd:libhttpd-src.jar",
         "//java/com/google/gerrit/index:libindex-src.jar",
         "//java/com/google/gerrit/index:libquery_exception-src.jar",
-        "//java/com/google/gerrit/index:libquery_parser-src.jar",
         "//java/com/google/gerrit/pgm/init/api:libapi-src.jar",
         "//java/com/google/gerrit/reviewdb:libserver-src.jar",
         "//java/com/google/gerrit/server:libserver-src.jar",
@@ -121,9 +123,9 @@
 java_doc(
     name = "plugin-api-javadoc",
     libs = PLUGIN_API + [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 4ebf98c..22342a6 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 4ebf98c77086477a5fa63e339a539b47d4e8d202
+Subproject commit 22342a6da26c75b14bc629331c339d1b820b4d39
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 315a115..4f6b685 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 315a11558913fa8f9c6d3b1723e45583b25afa1c
+Subproject commit 4f6b685e12e34a4f583cf84ba1c58ccc2b75e8b0
diff --git a/plugins/hooks b/plugins/hooks
index 07672f3..cc74144 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 07672f31880ba80300b38492df9d0acfcd6ee00a
+Subproject commit cc74144db755a18c5a63764a336b93ab3d1be1fe
diff --git a/plugins/replication b/plugins/replication
index 1086fac..d557ccc 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 1086faccd0cf2aa53977854767fdc77f048b0253
+Subproject commit d557ccc642c59a55750f560ce0d98870e1550d65
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index e4024e9..cc636d7 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit e4024e9d8d8139fc4c658c3af1a5e11e19b2d476
+Subproject commit cc636d7e36afb62455a9f045b125d246fd84afd0
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 660f54d..c119562 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -90,7 +90,7 @@
 
 1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
 2. Set up a local test site. Docs
-   [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
+   [here](https://gerrit-review.googlesource.com/Documentation/linux-quickstart.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
 When your project is set up and works using the classic UI, run a test server
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
index 1e19107..349cadc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -23,7 +23,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index ce0392d..799b831 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -213,7 +213,7 @@
             for (const key in response) {
               if (!response.hasOwnProperty(key)) { continue; }
               projects.push({
-                name: key,
+                name: response[key].name,
                 value: response[key].id,
               });
             }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
index f42652d..7db4e4c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
@@ -27,7 +27,12 @@
       }
     </style>
     <h3>[[title]]</h3>
-    <gr-button on-tap="_onCommandTap">[[title]]</gr-button>
+    <gr-button
+        title$="[[tooltip]]"
+        disabled$="[[disabled]]"
+        on-tap="_onCommandTap">
+      [[title]]
+    </gr-button>
   </template>
   <script src="gr-repo-command.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index e49c4de..bcdb7f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -22,6 +22,8 @@
 
     properties: {
       title: String,
+      disabled: Boolean,
+      tooltip: String,
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
index 935c967..dba01aa 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
@@ -50,7 +50,8 @@
               on-command-tap="_handleEditRepoConfig">
           </gr-repo-command>
           <gr-repo-command
-              title="Run GC"
+              title="[[_repoConfig.actions.gc.label]]"
+              tooltip="[[_repoConfig.actions.gc.title]]"
               hidden$="[[!_repoConfig.actions.gc.enabled]]"
               on-command-tap="_handleRunningGC">
           </gr-repo-command>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index 5560972..5a8b5b1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -120,12 +120,7 @@
           .then(repos => {
             // Late response.
             if (filter !== this._filter || !repos) { return; }
-            this._repos = Object.keys(repos)
-             .map(key => {
-               const repo = repos[key];
-               repo.name = key;
-               return repo;
-             });
+            this._repos = repos;
             this._loading = false;
           });
     },
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 5977714..d84dec5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -25,11 +25,20 @@
   // gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
   const DEFAULT_SECTIONS = [
     {
+      // Changes with unpublished draft comments. This section is omitted when
+      // viewing other users, so we don't need to filter anything out.
+      name: 'Has unpublished drafts',
+      query: 'has:draft',
+      selfOnly: true,
+      hideIfEmpty: true,
+    },
+    {
       // WIP open changes owned by viewing user. This section is omitted when
       // viewing other users, so we don't need to filter anything out.
       name: 'Work in progress',
       query: 'is:open owner:${user} is:wip',
       selfOnly: true,
+      hideIfEmpty: true,
     },
     {
       // Non-WIP open changes owned by viewed user. Filter out changes ignored
@@ -58,7 +67,8 @@
       // changes not owned by the viewing user (the one instance of
       // 'owner:self' is intentional and implements this logic).
       query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
-          '(owner:${user} OR reviewer:${user} OR assignee:${user})',
+          '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+          'OR cc:${user})',
       suffixForDashboard: '-age:4w limit:10',
     },
   ];
@@ -160,16 +170,10 @@
     _getUserDashboard(user, sections, title) {
       sections = sections
         .filter(section => (user === 'self' || !section.selfOnly))
-        .map(section => {
-          const dashboardSection = {
-            name: section.name,
-            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-          };
-          if (section.suffixForDashboard) {
-            dashboardSection.suffixForDashboard = section.suffixForDashboard;
-          }
-          return dashboardSection;
-        });
+        .map(section => Object.assign({}, section, {
+          name: section.name,
+          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+        }));
       return Promise.resolve({title, sections});
     },
 
@@ -197,45 +201,57 @@
       // in an async so that attachment to the DOM can take place first.
       const title = params.title || this._computeTitle(user);
       this.async(() => this.fire('title-change', {title}));
+      return this._reload();
+    },
 
+    /**
+     * Reloads the element.
+     *
+     * @return {Promise<!Object>}
+     */
+    _reload() {
       this._loading = true;
-
-      const dashboardPromise = params.project ?
-          this._getProjectDashboard(params.project, params.dashboard) :
+      const {project, dashboard, title, user, sections} = this.params;
+      const dashboardPromise = project ?
+          this._getProjectDashboard(project, dashboard) :
           this._getUserDashboard(
-              params.user || 'self',
-              params.sections || DEFAULT_SECTIONS,
-              params.title || this._computeTitle(params.user));
+              user || 'self',
+              sections || DEFAULT_SECTIONS,
+              title || this._computeTitle(user));
 
-      return dashboardPromise.then(dashboard => {
-        if (!dashboard) {
-          this._loading = false;
-          return;
+      return dashboardPromise.then(this._fetchDashboardChanges.bind(this))
+          .then(() => {
+            this.$.reporting.dashboardDisplayed();
+          }).catch(err => {
+            console.warn(err);
+          }).then(() => { this._loading = false; });
+    },
+
+    /**
+     * Fetches the changes for each dashboard section and sets this._results
+     * with the response.
+     *
+     * @param {!Object} res
+     * @return {Promise}
+     */
+    _fetchDashboardChanges(res) {
+      if (!res) { return Promise.resolve(); }
+      const queries = res.sections.map(section => {
+        if (section.suffixForDashboard) {
+          return section.query + ' ' + section.suffixForDashboard;
         }
-        const queries = dashboard.sections.map(section => {
-          if (section.suffixForDashboard) {
-            return section.query + ' ' + section.suffixForDashboard;
-          }
-          return section.query;
-        });
-        const req =
-            this.$.restAPI.getChanges(null, queries, null, this.options);
-        return req.then(response => {
-          this._loading = false;
-          this._results = response.map((results, i) => {
-            return {
-              sectionName: dashboard.sections[i].name,
-              query: dashboard.sections[i].query,
-              results,
-            };
-          });
-        });
-      }).then(() => {
-        this.$.reporting.dashboardDisplayed();
-      }).catch(err => {
-        this._loading = false;
-        console.warn(err);
+        return section.query;
       });
+
+      return this.$.restAPI.getChanges(null, queries, null, this.options)
+          .then(changes => {
+            this._results = changes.map((results, i) => ({
+              sectionName: res.sections[i].name,
+              query: res.sections[i].query,
+              results,
+            })).filter((section, i) => !res.sections[i].hideIfEmpty ||
+                section.results.length);
+          });
     },
 
     _computeUserHeaderClass(userParam) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index a1da018..cac2627 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -207,8 +207,11 @@
                     sections: [
                       {name: 'section 1', query: 'query 1'},
                       {name: 'section 2', query: 'query 2 for self'},
-                      {name: 'section 3', query: 'self only query'},
                       {
+                        name: 'section 3',
+                        query: 'self only query',
+                        selfOnly: true,
+                      }, {
                         name: 'section 4',
                         query: 'query 4',
                         suffixForDashboard: 'suffix',
@@ -239,6 +242,21 @@
       });
     });
 
+    test('hideIfEmpty sections', () => {
+      const sections = [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ];
+      getChangesStub.restore();
+      sandbox.stub(element.$.restAPI, 'getChanges')
+          .returns(Promise.resolve([[], ['nonempty']]));
+
+      return element._fetchDashboardChanges({sections}).then(() => {
+        assert.equal(element._results.length, 1);
+        assert.equal(element._results[0].sectionName, 'test2');
+      });
+    });
+
     test('_computeUserHeaderClass', () => {
       assert.equal(element._computeUserHeaderClass(undefined), '');
       assert.equal(element._computeUserHeaderClass(''), '');
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index b12b906..6af94a1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -212,6 +212,12 @@
      * @event show-alert
      */
 
+    /**
+     * Fires when a change action fails.
+     *
+     * @event show-error
+     */
+
     properties: {
       /**
        * @type {{
@@ -738,7 +744,7 @@
         } else if (!values.includes(a)) {
           return;
         }
-        actions[a].label = this._getActionLabel(actions[a], type);
+        actions[a].label = this._getActionLabel(actions[a]);
 
         // Triggers a re-render by ensuring object inequality.
         result.push(Object.assign({}, actions[a]));
@@ -768,15 +774,15 @@
      * Given a change action, return a display label that uses the appropriate
      * casing or includes explanatory details.
      */
-    _getActionLabel(action, type) {
-      if (action.label === 'Delete' && type === ActionType.CHANGE) {
+    _getActionLabel(action) {
+      if (action.label === 'Delete') {
         // This label is common within change and revision actions. Make it more
         // explicit to the user.
         return 'Delete change';
-      } else if (action.label === 'WIP' && type === ActionType.CHANGE) {
+      } else if (action.label === 'WIP') {
         return 'Mark as work in progress';
       }
-      // Otherwise, just map the anme to sentence case.
+      // Otherwise, just map the name to sentence case.
       return this._toSentenceCase(action.label);
     },
 
@@ -1154,7 +1160,7 @@
 
     _handleResponseError(response) {
       return response.text().then(errText => {
-        this.fire('show-alert',
+        this.fire('show-error',
             {message: `Could not perform action: ${errText}`});
         if (!errText.startsWith('Change is already up to date')) {
           throw Error(errText);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index cfd2c45..91b39de 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -1370,6 +1370,7 @@
     suite('_send', () => {
       let cleanup;
       let payload;
+      let onShowError;
       let onShowAlert;
 
       setup(() => {
@@ -1378,6 +1379,8 @@
         element.latestPatchNum = 12;
         payload = {foo: 'bar'};
 
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
         element.addEventListener('show-alert', onShowAlert);
       });
@@ -1395,7 +1398,7 @@
         test('change action', () => {
           return element._send('DELETE', payload, '/endpoint', false, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
                     null, payload));
@@ -1405,7 +1408,7 @@
         test('revision action', () => {
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
                     12, payload));
@@ -1423,6 +1426,7 @@
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
                 assert.isTrue(onShowAlert.calledOnce);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isFalse(sendStub.called);
               });
@@ -1441,7 +1445,7 @@
 
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
-                assert.isFalse(onShowAlert.called);
+                assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.called);
                 assert.isTrue(sendStub.calledOnce);
                 assert.isTrue(handleErrorStub.called);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index 939ac67..1431887 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
@@ -56,7 +56,6 @@
         float: right;
       }
       .title {
-        font-weight: bold;
         min-width: 10em;
         padding: .75em .5em 0 var(--requirements-horizontal-padding);
         vertical-align: top;
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 76d7f1e..461bfc4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -606,6 +606,7 @@
     </gr-overlay>
     <gr-overlay id="uploadHelpOverlay" with-backdrop>
       <gr-upload-help-dialog
+          target-branch="[[_change.branch]]"
           on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
     </gr-overlay>
     <gr-overlay id="includedInOverlay" with-backdrop>
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 e905e038..ebd72ad 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -122,7 +122,7 @@
       _changeComments: Object,
       _canStartReview: {
         type: Boolean,
-        computed: '_computeCanStartReview(_loggedIn, _change, _account)',
+        computed: '_computeCanStartReview(_change)',
       },
       _comments: Object,
       /** @type {?} */
@@ -1341,9 +1341,9 @@
       });
     },
 
-    _computeCanStartReview(loggedIn, change, account) {
-      return !!(loggedIn && change.work_in_progress &&
-          change.owner._account_id === account._account_id);
+    _computeCanStartReview(change) {
+      return !!(change.actions && change.actions.ready &&
+          change.actions.ready.enabled);
     },
 
     _computeReplyDisabled() { return false; },
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 b5b8cd9..4e6cb6e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -1411,18 +1411,24 @@
       });
 
       test('canStartReview computation', () => {
-        const account1 = {_account_id: 1};
-        const account2 = {_account_id: 2};
-        const change = {
-          owner: {_account_id: 1},
+        const change1 = {};
+        const change2 = {
+          actions: {
+            ready: {
+              enabled: true,
+            },
+          },
         };
-        assert.isFalse(element._computeCanStartReview(true, change, account1));
-        change.work_in_progress = false;
-        assert.isFalse(element._computeCanStartReview(true, change, account1));
-        change.work_in_progress = true;
-        assert.isTrue(element._computeCanStartReview(true, change, account1));
-        assert.isFalse(element._computeCanStartReview(false, change, account1));
-        assert.isFalse(element._computeCanStartReview(true, change, account2));
+        const change3 = {
+          actions: {
+            ready: {
+              label: 'Ready for Review',
+            },
+          },
+        };
+        assert.isFalse(element._computeCanStartReview(change1));
+        assert.isTrue(element._computeCanStartReview(change2));
+        assert.isFalse(element._computeCanStartReview(change3));
       });
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 954507e..9996abc 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -40,6 +40,7 @@
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
     });
 
     teardown(() => { sandbox.restore(); });
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
index 96a5454..b6c8fcc 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -39,7 +39,6 @@
           has-tooltip
           button-title="Copy full SHA to clipboard"
           hide-input
-          hide-label
           text="[[commitInfo.commit]]">
       </gr-copy-clipboard>
     </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index ef423fb..fa984c9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -23,7 +23,7 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-diff/gr-diff.html">
+<link rel="import" href="../../diff/gr-diff-host/gr-diff-host.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../edit/gr-edit-file-controls/gr-edit-file-controls.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -389,8 +389,9 @@
           </div>
           <template is="dom-if"
               if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
-            <gr-diff
+            <gr-diff-host
                 no-auto-render
+                show-load-failure
                 display-line="[[_displayLine]]"
                 inline-index=[[index]]
                 hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
@@ -402,7 +403,7 @@
                 project-config="[[projectConfig]]"
                 on-line-selected="_onLineSelected"
                 no-render-on-prefs-change
-                view-mode="[[diffViewMode]]"></gr-diff>
+                view-mode="[[diffViewMode]]"></gr-diff-host>
           </template>
         </div>
       </template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index d3b4043..f54e058 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -283,7 +283,7 @@
     },
 
     get diffs() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff');
+      return Polymer.dom(this.root).querySelectorAll('gr-diff-host');
     },
 
     openDiffPrefs() {
@@ -843,10 +843,9 @@
     },
 
     _updateDiffCursor() {
-      const diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
       // Overwrite the cursor's list of diffs:
       this.$.diffCursor.splice(
-          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
     },
 
     _filesChanged() {
@@ -976,7 +975,7 @@
      * for each path in order, awaiting the previous render to complete before
      * continung.
      * @param  {!Array<string>} paths
-     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+     * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
      * @param  {number} initialCount The total number of paths in the pass. This
      *   is used to generate log messages.
      * @return {!Promise}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index c1a6c05..88b5f66 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -69,7 +69,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
@@ -830,7 +830,7 @@
 
       // Click inside the diff. This should result in no additional calls to
       // _togglePathExpanded or _reviewFile.
-      Polymer.dom(element.root).querySelector('gr-diff').click();
+      Polymer.dom(element.root).querySelector('gr-diff-host').click();
       assert.isTrue(tapSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
@@ -1024,6 +1024,8 @@
           done();
         },
         cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) { callback(new Event(eventName)); },
       }];
       sinon.stub(element, 'diffs', {
         get() { return diffs; },
@@ -1354,7 +1356,6 @@
 
     const setupDiff = function(diff) {
       const mock = document.createElement('mock-diff-response');
-      diff._diff = mock.diffResponse;
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1382,12 +1383,12 @@
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff._renderDiffTable();
+      diff._diff = mock.diffResponse;
     };
 
     const renderAndGetNewDiffs = function(index) {
       const diffs =
-          Polymer.dom(element.root).querySelectorAll('gr-diff');
+          Polymer.dom(element.root).querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
         setupDiff(diffs[i]);
@@ -1410,7 +1411,7 @@
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
-      stub('gr-diff', {
+      stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 9a96120..a7abf85 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -211,15 +211,37 @@
     },
 
     /**
-     * Do the given objects describe the same change?
-     * (Compares the change objects by their change numbers.)
+     * Do the given objects describe the same change? Compares the changes by
+     * their numbers.
      * @see /Documentation/rest-api-changes.html#change-info
-     * @param {!Object} a
-     * @param {!Object} b
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
      * @return {boolean}
      */
     _changesEqual(a, b) {
-      return a._number === b._number;
+      const aNum = this._getChangeNumber(a);
+      const bNum = this._getChangeNumber(b);
+      return aNum === bNum;
+    },
+
+    /**
+     * Get the change number from either a ChangeInfo (such as those included in
+     * SubmittedTogetherInfo responses) or get the change number from a
+     * RelatedChangeAndCommitInfo (such as those included in a
+     * RelatedChangesInfo response).
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     *
+     * @param {!Object} change Either a ChangeInfo or a
+     *     RelatedChangeAndCommitInfo object.
+     * @return {number}
+     */
+    _getChangeNumber(change) {
+      if (change.hasOwnProperty('_change_number')) {
+        return change._change_number;
+      }
+      return change._number;
     },
 
     _computeLinkClass(change) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index c208f01..ef4af16 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -224,7 +224,7 @@
 
     test('_computeChangeContainerClass', () => {
       const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _number: 1};
+      const change2 = {change_id: 456, _change_number: 1};
       const change3 = {change_id: 123, _number: 2};
 
       assert.notEqual(element._computeChangeContainerClass(
@@ -239,10 +239,19 @@
       const change1 = {change_id: 123, _number: 0};
       const change2 = {change_id: 456, _number: 1};
       const change3 = {change_id: 123, _number: 2};
+      const change4 = {change_id: 123, _change_number: 1};
 
       assert.isTrue(element._changesEqual(change1, change1));
       assert.isFalse(element._changesEqual(change1, change2));
       assert.isFalse(element._changesEqual(change1, change3));
+      assert.isTrue(element._changesEqual(change2, change4));
+    });
+
+    test('_getChangeNumber', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      assert.equal(element._getChangeNumber(change1), 0);
+      assert.equal(element._getChangeNumber(change2), 1);
     });
 
     test('event for section loaded fires for each section ', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
index 072e7113..a9843a3 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -17,6 +17,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-upload-help-dialog">
@@ -33,32 +34,9 @@
         margin-left: 1em;
         list-style: decimal;
       }
-      p,
-      .commandContainer {
+      p {
         margin-bottom: .75em;
       }
-      .commandContainer {
-        background: #f5f5f5;
-        padding: .5em .5em .5em 2.5em;
-        position: relative;
-        width: 100%;
-      }
-      .commandContainer:before {
-        background: #ebebeb;
-        bottom: 0;
-        content: '$';
-        display: block;
-        left: 0;
-        padding: .8em;
-        position: absolute;
-        top: 0;
-        width: 2em;
-      }
-      .commandContainer gr-copy-clipboard {
-        --text-container-style: {
-          border: none;
-        }
-      }
     </style>
     <gr-dialog
         confirm-label="Done"
@@ -78,18 +56,14 @@
               Update the local commit with your modifications using the following
               command.
             </p>
-            <div class="commandContainer">
-              <gr-copy-clipboard text="[[_commitCommand]]"></gr-copy-clipboard>
-            </div>
+            <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
             <p>
               Leave the "Change-Id:" line of the commit message as is.
             </p>
           </li>
           <li>
             <p>Push the updated commit to Gerrit.</p>
-            <div class="commandContainer">
-              <gr-copy-clipboard text="[[_pushCommand]]"></gr-copy-clipboard>
-            </div>
+            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
           </li>
           <li>
             <p>Refresh this page to view the the update.</p>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index d796999..548116c 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -18,7 +18,7 @@
   'use strict';
 
   const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-  const PUSH_COMMAND = 'git push origin HEAD:refs/for/master';
+  const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
 
   Polymer({
     is: 'gr-upload-help-dialog',
@@ -30,6 +30,7 @@
      */
 
     properties: {
+      targetBranch: String,
       _commitCommand: {
         type: String,
         value: COMMIT_COMMAND,
@@ -37,8 +38,7 @@
       },
       _pushCommand: {
         type: String,
-        value: PUSH_COMMAND,
-        readOnly: true,
+        computed: '_computePushCommand(targetBranch)',
       },
     },
 
@@ -46,5 +46,9 @@
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
+
+    _computePushCommand(targetBranch) {
+      return PUSH_COMMAND_PREFIX + targetBranch;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
new file mode 100644
index 0000000..60fe3e6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-upload-help-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-upload-help-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-upload-help-dialog></gr-upload-help-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-upload-help-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('constructs push command from branch', () => {
+      element.targetBranch = 'foo';
+      assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
+
+      element.targetBranch = 'master';
+      assert.equal(element._pushCommand,
+          'git push origin HEAD:refs/for/master');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
new file mode 100644
index 0000000..f8bf33c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
@@ -0,0 +1,49 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-error-dialog">
+  <template>
+    <style include="shared-styles">
+      .main {
+        max-height: 40em;
+        max-width: 60em;
+        overflow-y: auto;
+        white-space: pre-wrap;
+      }
+      @media screen and (max-width: 50em) {
+        .main {
+          max-height: none;
+          max-width: 50em;
+        }
+      }
+    </style>
+    <gr-dialog
+        id="dialog"
+        cancel-label=""
+        on-confirm="_handleConfirm"
+        confirm-label="Dismiss"
+        confirm-on-enter>
+      <div class="header" slot="header">An error occurred</div>
+      <div class="main" slot="main">[[text]]</div>
+    </gr-dialog>
+  </template>
+  <script src="gr-error-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
new file mode 100644
index 0000000..8d3b58e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-error-dialog',
+
+    /**
+     * Fired when the dismiss button is pressed.
+     *
+     * @event dismiss
+     */
+
+    properties: {
+      text: String,
+    },
+
+    _handleConfirm() {
+      this.dispatchEvent(new CustomEvent('dismiss'));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
new file mode 100644
index 0000000..e2c314b
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-error-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-error-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-error-dialog></gr-error-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-error-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('dismiss tap fires event', done => {
+      element.addEventListener('dismiss', () => { done(); });
+      MockInteractions.tap(element.$.dialog.$.confirm);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 95c5403..3ec4bb5 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -17,11 +17,20 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
 <link rel="import" href="../../shared/gr-alert/gr-alert.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-error-manager">
   <template>
+    <gr-overlay with-backdrop id="errorOverlay">
+      <gr-error-dialog
+          id="errorDialog"
+          on-dismiss="_handleDismissErrorDialog"
+          confirm-label="Dismiss"
+          confirm-on-enter></gr-error-dialog>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-error-manager.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 758148e..e3596e7 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -62,6 +62,7 @@
       this.listen(document, 'network-error', '_handleNetworkError');
       this.listen(document, 'auth-error', '_handleAuthError');
       this.listen(document, 'show-alert', '_handleShowAlert');
+      this.listen(document, 'show-error', '_handleShowErrorDialog');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
       this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
@@ -73,6 +74,7 @@
       this.unlisten(document, 'auth-error', '_handleAuthError');
       this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+      this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     },
 
     _shouldSuppressError(msg) {
@@ -101,7 +103,7 @@
           // This indicates the auth token is no longer valid.
           this._handleAuthError();
         } else if (!this._shouldSuppressError(text)) {
-          this._showAlert('Server error: ' + text);
+          this._showErrorDialog('Server error: ' + text);
         }
         console.error(text);
       });
@@ -257,5 +259,18 @@
     _handleWindowFocus() {
       this.flushDebouncer('checkLoggedIn');
     },
+
+    _handleShowErrorDialog(e) {
+      this._showErrorDialog(e.detail.message);
+    },
+
+    _handleDismissErrorDialog() {
+      this.$.errorOverlay.close();
+    },
+
+    _showErrorDialog(message) {
+      this.$.errorDialog.text = message;
+      this.$.errorOverlay.open();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index c4ba8d2..e28b979 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -87,7 +87,7 @@
     });
 
     test('show normal server error', done => {
-      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
       const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
@@ -96,8 +96,8 @@
         element.$.restAPI.getLoggedIn.lastCall.returnValue,
         textSpy.lastCall.returnValue,
       ]).then(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
         done();
       });
@@ -279,5 +279,21 @@
       element._showAlert();
       assert.isTrue(hideStub.calledOnce);
     });
+
+    test('show-error', () => {
+      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
+      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
+      const message = 'test message';
+      element.fire('show-error', {message});
+      flushAsynchronousOperations();
+
+      assert.isTrue(openStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.fire('dismiss');
+      flushAsynchronousOperations();
+
+      assert.isTrue(closeStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index a700ccd..c5e32cc 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -71,10 +71,15 @@
     //    - `detail`, optional, String: the name of the repo detail view.
     //      Takes any value from Gerrit.Nav.RepoDetailView.
     //
+    //  - Gerrit.Nav.View.DASHBOARD
+    //    - `repo`, optional, String.
+    //    - `sections`, optional, Array of objects with `title` and `query`
+    //      strings.
+    //    - `user`, optional, String.
+    //
     //  - Gerrit.Nav.View.ROOT:
     //    - no possible parameters.
 
-
     window.Gerrit = window.Gerrit || {};
 
     // Prevent redefinition.
@@ -132,6 +137,9 @@
       /** @type {Function} */
       _generateWeblinks: uninitialized,
 
+      /** @type {Function} */
+      mapCommentlinks: uninitialized,
+
       /**
        * @param {number=} patchNum
        * @param {number|string=} basePatchNum
@@ -144,20 +152,38 @@
 
       /**
        * Setup router implementation.
-       * @param {Function} navigate
-       * @param {Function} generateUrl
-       * @param {Function} generateWeblinks
+       * @param {function(!string)} navigate the router-abstracted equivalent of
+       *     `window.location.href = ...`. Takes a string.
+       * @param {function(!Object): string} generateUrl generates a URL given
+       *     navigation parameters, detailed in the file header.
+       * @param {function(!Object): string} generateWeblinks weblinks generator
+       *     function takes single payload parameter with type property that
+       *  determines which
+       *     part of the UI is the consumer of the weblinks. type property can
+       *     be one of file, change, or patchset.
+       *     - For file type, payload will also contain string properties: repo,
+       *         commit, file.
+       *     - For patchset type, payload will also contain string properties:
+       *         repo, commit.
+       *     - For change type, payload will also contain string properties:
+       *         repo, commit. If server provides weblinks, those will be passed
+       *         as options.weblinks property on the main payload object.
+       * @param {function(!Object): Object} mapCommentlinks provides an escape
+       *     hatch to modify the commentlinks object, e.g. if it contains any
+       *     relative URLs.
        */
-      setup(navigate, generateUrl, generateWeblinks) {
+      setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
         this._navigate = navigate;
         this._generateUrl = generateUrl;
         this._generateWeblinks = generateWeblinks;
+        this.mapCommentlinks = mapCommentlinks;
       },
 
       destroy() {
         this._navigate = uninitialized;
         this._generateUrl = uninitialized;
         this._generateWeblinks = uninitialized;
+        this.mapCommentlinks = uninitialized;
       },
 
       /**
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 a72feb1..bdd0942 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -176,6 +176,8 @@
 
   const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
+  const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   const app = document.querySelector('#app');
@@ -394,7 +396,8 @@
         suffix += ',edit';
       }
       if (params.project) {
-        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        const encodedProject = this.encodeURL(params.project, true);
+        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
@@ -405,20 +408,19 @@
      * @return {string}
      */
     _generateDashboardUrl(params) {
+      const repoName = params.repo || params.project || null;
       if (params.sections) {
         // Custom dashboard.
-        const queryParams = params.sections.map(section => {
-          return encodeURIComponent(section.name) + '=' +
-              encodeURIComponent(section.query);
-        });
+        const queryParams = this._sectionsToEncodedParams(params.sections,
+            repoName);
         if (params.title) {
           queryParams.push('title=' + encodeURIComponent(params.title));
         }
         const user = params.user ? params.user : '';
         return `/dashboard/${user}?${queryParams.join('&')}`;
-      } else if (params.project) {
+      } else if (repoName) {
         // Project dashboard.
-        return `/p/${params.project}/+/dashboard/${params.dashboard}`;
+        return `/p/${repoName}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
@@ -426,6 +428,23 @@
     },
 
     /**
+     * @param {!Array<!{name: string, query: string}>} sections
+     * @param {string=} opt_repoName
+     * @return {!Array<string>}
+     */
+    _sectionsToEncodedParams(sections, opt_repoName) {
+      return sections.map(section => {
+        // If there is a repo name provided, make sure to substitute it into the
+        // ${repo} (or legacy ${project}) query tokens.
+        const query = opt_repoName ?
+            section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+            section.query;
+        return encodeURIComponent(section.name) + '=' +
+            encodeURIComponent(query);
+      });
+    },
+
+    /**
      * @param {!Object} params
      * @return {string}
      */
@@ -444,7 +463,8 @@
       }
 
       if (params.project) {
-        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        const encodedProject = this.encodeURL(params.project, true);
+        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
@@ -660,7 +680,8 @@
       Gerrit.Nav.setup(
           url => { page.show(url); },
           this._generateUrl.bind(this),
-          params => this._generateWeblinks(params)
+          params => this._generateWeblinks(params),
+          x => x
       );
 
       page.exit('*', (ctx, next) => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index b68a5e9..53a7c07 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -283,6 +283,16 @@
             '/c/test/+/1234/5..10?revert&foo=bar');
       });
 
+      test('change with repo name encoding', () => {
+        const params = {
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: '1234',
+          project: 'x+/y+/z+/w',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/x%252B/y%252B/z%252B/w/+/1234');
+      });
+
       test('diff', () => {
         const params = {
           view: Gerrit.Nav.View.DIFF,
@@ -317,6 +327,18 @@
             '/c/test/+/42/2/file.cpp#b123');
       });
 
+      test('diff with repo name encoding', () => {
+        const params = {
+          view: Gerrit.Nav.View.DIFF,
+          changeNum: '42',
+          path: 'x+y/path.cpp',
+          patchNum: 12,
+          project: 'x+/y',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+      });
+
       test('edit', () => {
         const params = {
           view: Gerrit.Nav.View.EDIT,
@@ -375,6 +397,21 @@
               '/dashboard/?section%201=query%201&section%202=query%202');
         });
 
+        test('custom repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            sections: [
+              {name: 'section 1', query: 'query 1 ${project}'},
+              {name: 'section 2', query: 'query 2 ${repo}'},
+            ],
+            repo: 'repo-name',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/?section%201=query%201%20repo-name&' +
+              'section%202=query%202%20repo-name');
+        });
+
         test('custom user dashboard, with title', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
@@ -387,7 +424,18 @@
               '/dashboard/user?name=query&title=custom%20dashboard');
         });
 
-        test('project dashboard', () => {
+        test('repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            repo: 'gerrit/repo',
+            dashboard: 'default:main',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/p/gerrit/repo/+/dashboard/default:main');
+        });
+
+        test('project dashboard (legacy)', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
             project: 'gerrit/project',
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 657c8e0..3a48213 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -30,7 +30,7 @@
       gr-autocomplete {
         background-color: var(--view-background-color);
         border: 1px solid var(--border-color);
-        border-radius: 2px 0 0 2px;
+        border-radius: 2px;
         flex: 1;
         font: inherit;
         outline: none;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index cb768ef..88ff79b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -35,7 +35,6 @@
   GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
 
   GrDiffBuilderImage.prototype.renderDiff = function() {
-    this._outputEl.classList.add('image-diff');
     const section = this._createElement('tbody', 'image-diff');
 
     this._emitImagePair(section);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 8a89937..9668a54 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -35,6 +35,7 @@
     <mock-diff-response></mock-diff-response>
     <gr-diff></gr-diff>
     <gr-diff-cursor></gr-diff-cursor>
+    <gr-rest-api-interface></gr-rest-api-interface>
   </template>
 </test-fixture>
 
@@ -48,27 +49,18 @@
     setup(done => {
       sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
       const fixtureElems = fixture('basic');
       mockDiffResponse = fixtureElems[0];
       diffElement = fixtureElems[1];
       cursorElement = fixtureElems[2];
+      const restAPI = fixtureElems[3];
 
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
+      diffElement.loggedIn = false;
+      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
       diffElement.comments = {left: [], right: []};
-      diffElement.$.restAPI.getDiffPreferences().then(prefs => {
-        diffElement.prefs = prefs;
-      });
-
-      sandbox.stub(diffElement, '_getDiff', () => {
-        return Promise.resolve(mockDiffResponse.diffResponse);
-      });
-
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
@@ -77,7 +69,10 @@
       };
       diffElement.addEventListener('render', setupDone);
 
-      diffElement.reload();
+      restAPI.getDiffPreferences().then(prefs => {
+        diffElement.prefs = prefs;
+        diffElement.diff = mockDiffResponse.diffResponse;
+      });
     });
 
     teardown(() => sandbox.restore());
@@ -219,7 +214,7 @@
         done();
       }
       diffElement.addEventListener('render', renderHandler);
-      diffElement.reload();
+      diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
     test('initialLineNumber enabled', done => {
@@ -239,7 +234,7 @@
       cursorElement.initialLineNumber = 10;
       cursorElement.side = 'right';
 
-      diffElement.reload();
+      diffElement._diffChanged(mockDiffResponse.diffResponse);
     });
 
     test('getAddress', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index a45f8ec..cee3cad 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -156,11 +156,10 @@
 
     /**
      * Adjust triple click selection for the whole line.
-     * domRange.endContainer may be one of the following:
-     * 1) 0 offset at right column's line number cell, or
-     * 2) 0 offset at left column's line number at the next line.
-     * Case 1 means left column was triple clicked.
-     * Case 2 means right column or unified view triple clicked.
+     * A triple click always results in:
+     * - start.column == end.column == 0
+     * - end.line == start.line + 1
+     *
      * @param {!Object} range Normalized range, ie column/line numbers
      * @param {!Range} domRange DOM Range object
      * @return {!Object} fixed normalized range
@@ -172,20 +171,13 @@
       }
       const start = range.start;
       const end = range.end;
-      const endsAtOtherSideLineNum =
-          domRange.endOffset === 0 &&
-          domRange.endContainer.nodeName === 'TD' &&
-          (domRange.endContainer.classList.contains('left') ||
-              domRange.endContainer.classList.contains('right'));
-      const endsOnOtherSideStart = endsAtOtherSideLineNum ||
-          end &&
+      const endsAtBeginningOfNextLine = end &&
+          start.column === 0 &&
           end.column === 0 &&
-          end.line === start.line &&
-          end.side != start.side;
+          end.line === start.line + 1;
       const content = domRange.cloneContents().querySelector('.contentText');
       const lineLength = content && this._getLength(content) || 0;
-      if (lineLength && endsOnOtherSideStart || endsAtOtherSideLineNum) {
-        // Selection ends at the beginning of the next line.
+      if (lineLength && endsAtBeginningOfNextLine) {
         // Move the selection to the end of the previous line.
         range.end = {
           node: start.node,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index a82a11e..7b19338 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -576,54 +576,18 @@
         assert.equal(result, 0);
       });
 
-      // TODO (viktard): Selection starts in line number.
-      // TODO (viktard): Empty lines in selection start.
-      // TODO (viktard): Empty lines in selection end.
-      // TODO (viktard): Only empty lines selected.
-      // TODO (viktard): Unified mode.
-
-      suite('triple click', () => {
-        test('_fixTripleClickSelection', () => {
-          const fakeRange = {
-            startContainer: '',
-            startOffset: '',
-            endContainer: '',
-            endOffset: '',
-          };
-          const fixedRange = {};
-          sandbox.stub(GrRangeNormalizer, 'normalize').returns(fakeRange);
-          sandbox.stub(element, '_normalizeSelectionSide');
-          sandbox.stub(element, '_fixTripleClickSelection').returns(fixedRange);
-          assert.strictEqual(element._normalizeRange({}), fixedRange);
-          assert.isTrue(element._fixTripleClickSelection.called);
+      test('_fixTripleClickSelection', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
+        emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 0,
+          endLine: 119,
+          endChar: element._getLength(startContent),
         });
-
-        test('left pane', () => {
-          const startNode = stubContent(138, 'left');
-          const endNode =
-              stubContent(119, 'right').parentElement.previousElementSibling;
-          builder.getLineNumberByChild.withArgs(endNode).returns(119);
-          emulateSelection(startNode, 0, endNode, 0);
-          assert.deepEqual(getActionRange(), {
-            startLine: 138,
-            startChar: 0,
-            endLine: 138,
-            endChar: 63,
-          });
-        });
-
-        test('right pane', () => {
-          const startNode = stubContent(119, 'right');
-          const endNode =
-              stubContent(140, 'left').parentElement.previousElementSibling;
-          emulateSelection(startNode, 0, endNode, 0);
-          assert.deepEqual(getActionRange(), {
-            startLine: 119,
-            startChar: 0,
-            endLine: 119,
-            endChar: 63,
-          });
-        });
+        assert.equal(getActionSide(), 'right');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
new file mode 100644
index 0000000..e3bf866
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -0,0 +1,55 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-diff/gr-diff.html">
+
+<dom-module id="gr-diff-host">
+  <template>
+    <gr-diff
+        id="diff"
+        change-num="[[changeNum]]"
+        no-auto-render=[[noAutoRender]]
+        patch-range="[[patchRange]]"
+        path="[[path]]"
+        prefs="[[prefs]]"
+        project-config="[[projectConfig]]"
+        project-name="[[projectName]]"
+        display-line="[[displayLine]]"
+        is-image-diff="[[isImageDiff]]"
+        commit-range="[[commitRange]]"
+        hidden$="[[hidden]]"
+        no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+        comments="[[comments]]"
+        line-wrapping="[[lineWrapping]]"
+        view-mode="[[viewMode]]"
+        line-of-interest="[[lineOfInterest]]"
+        logged-in="[[_loggedIn]]"
+        loading="[[_loading]]"
+        error-message="[[_errorMessage]]"
+        base-image="[[_baseImage]]"
+        revision-image=[[_revisionImage]]
+        blame="[[_blame]]"
+        diff="[[_diff]]"></gr-diff>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting" category="diff"></gr-reporting>
+  </template>
+  <script src="gr-diff-host.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
new file mode 100644
index 0000000..3e9e796
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -0,0 +1,434 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+
+  const EVENT_AGAINST_PARENT = 'diff-against-parent';
+  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+
+  const DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  /**
+   * @param {Object} diff
+   * @return {boolean}
+   */
+  function isImageDiff(diff) {
+    if (!diff) { return false; }
+
+    const isA = diff.meta_a &&
+        diff.meta_a.content_type.startsWith('image/');
+    const isB = diff.meta_b &&
+        diff.meta_b.content_type.startsWith('image/');
+
+    return !!(diff.binary && (isA || isB));
+  }
+
+  /**
+   * Wrapper around gr-diff.
+   *
+   * Webcomponent fetching diffs and related data from restAPI and passing them
+   * to the presentational gr-diff for rendering.
+   */
+  // TODO(oler): Move all calls to restAPI from gr-diff here.
+  Polymer({
+    is: 'gr-diff-host',
+
+    /**
+     * Fired when the user selects a line.
+     * @event line-selected
+     */
+
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
+    /**
+     * Fired when a comment is saved or discarded
+     *
+     * @event diff-comments-modified
+     */
+
+    properties: {
+      changeNum: String,
+      noAutoRender: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+      },
+      projectConfig: {
+        type: Object,
+      },
+      projectName: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
+      isImageDiff: {
+        type: Boolean,
+        computed: '_computeIsImageDiff(_diff)',
+        notify: true,
+      },
+      commitRange: Object,
+      filesWeblinks: {
+        type: Object,
+        value() { return {}; },
+        notify: true,
+      },
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      noRenderOnPrefsChange: Boolean,
+      comments: Object,
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+      },
+      viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+      },
+
+      /**
+       * Special line number which should not be collapsed into a shared region.
+       * @type {{
+       *  number: number,
+       *  leftSide: {boolean}
+       * }|null}
+       */
+      lineOfInterest: Object,
+
+      /**
+       * If the diff fails to load, show the failure message in the diff rather
+       * than bubbling the error up to the whole page. This is useful for when
+       * loading inline diffs because one diff failing need not mark the whole
+       * page with a failure.
+       */
+      showLoadFailure: Boolean,
+
+      isBlameLoaded: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeIsBlameLoaded(_blame)',
+      },
+
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+
+      /** @type {?string} */
+      _errorMessage: {
+        type: String,
+        value: null,
+      },
+
+      /** @type {?Object} */
+      _baseImage: Object,
+      /** @type {?Object} */
+      _revisionImage: Object,
+
+      _diff: Object,
+
+      /** @type {?Object} */
+      _blame: {
+        type: Object,
+        value: null,
+      },
+    },
+
+    listeners: {
+      'draft-interaction': '_handleDraftInteraction',
+    },
+
+    ready() {
+      if (this._canReload()) {
+        this.reload();
+      }
+    },
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
+    },
+
+    /** @return {!Promise} */
+    reload() {
+      this._loading = true;
+      this._errorMessage = null;
+
+      const diffRequest = this._getDiff()
+          .then(diff => {
+            this._reportDiff(diff);
+            return diff;
+          })
+          .catch(e => {
+            this._handleGetDiffError(e);
+            return null;
+          });
+
+      const assetRequest = diffRequest.then(diff => {
+        // If the diff is null, then it's failed to load.
+        if (!diff) { return null; }
+
+        return this._loadDiffAssets(diff);
+      });
+
+      return Promise.all([diffRequest, assetRequest])
+          .then(results => {
+            const diff = results[0];
+            if (!diff) {
+              return Promise.resolve();
+            }
+            this.filesWeblinks = this._getFilesWeblinks(diff);
+            return new Promise(resolve => {
+              const callback = () => {
+                resolve();
+                this.removeEventListener('render', callback);
+              };
+              this.addEventListener('render', callback);
+              this._diff = diff;
+            });
+          })
+          .catch(err => {
+            console.warn('Error encountered loading diff:', err);
+          })
+          .then(() => { this._loading = false; });
+    },
+
+    _getFilesWeblinks(diff) {
+      if (!this.commitRange) { return {}; }
+      return {
+        meta_a: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.baseCommit, this.path,
+            {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+        meta_b: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.commit, this.path,
+            {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
+      };
+    },
+
+    /** Cancel any remaining diff builder rendering work. */
+    cancel() {
+      this.$.diff.cancel();
+    },
+
+    /** @return {!Array<!HTMLElement>} */
+    getCursorStops() {
+      return this.$.diff.getCursorStops();
+    },
+
+    /** @return {boolean} */
+    isRangeSelected() {
+      return this.$.diff.isRangeSelected();
+    },
+
+    toggleLeftDiff() {
+      this.$.diff.toggleLeftDiff();
+    },
+
+    /**
+     * Load and display blame information for the base of the diff.
+     * @return {Promise} A promise that resolves when blame finishes rendering.
+     */
+    loadBlame() {
+      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+          this.path, true)
+          .then(blame => {
+            if (!blame.length) {
+              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
+              return Promise.reject(MSG_EMPTY_BLAME);
+            }
+
+            this._blame = blame;
+          });
+    },
+
+    /** Unload blame information for the diff. */
+    clearBlame() {
+      this._blame = null;
+    },
+
+    /** @return {!Array<!HTMLElement>} */
+    getThreadEls() {
+      return this.$.diff.getThreadEls();
+    },
+
+    /** @param {HTMLElement} el */
+    addDraftAtLine(el) {
+      this.$.diff.addDraftAtLine(el);
+    },
+
+    clearDiffContent() {
+      this.$.diff.clearDiffContent();
+    },
+
+    expandAllContext() {
+      this.$.diff.expandAllContext();
+    },
+
+    /** @return {!Promise} */
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    /** @return {boolean}} */
+    _canReload() {
+      return !!this.changeNum && !!this.patchRange && !!this.path &&
+          !this.noAutoRender;
+    },
+
+    /** @return {!Promise<!Object>} */
+    _getDiff() {
+      // Wrap the diff request in a new promise so that the error handler
+      // rejects the promise, allowing the error to be handled in the .catch.
+      return new Promise((resolve, reject) => {
+        this.$.restAPI.getDiff(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path,
+            reject)
+            .then(resolve);
+      });
+    },
+
+    _handleGetDiffError(response) {
+      // Loading the diff may respond with 409 if the file is too large. In this
+      // case, use a toast error..
+      if (response.status === 409) {
+        this.fire('server-error', {response});
+        return;
+      }
+
+      if (this.showLoadFailure) {
+        this._errorMessage = [
+          'Encountered error when loading the diff:',
+          response.status,
+          response.statusText,
+        ].join(' ');
+        return;
+      }
+
+      this.fire('page-error', {response});
+    },
+
+    /**
+     * Report info about the diff response.
+     */
+    _reportDiff(diff) {
+      if (!diff || !diff.content) { return; }
+
+      // Count the delta lines stemming from normal deltas, and from
+      // due_to_rebase deltas.
+      let nonRebaseDelta = 0;
+      let rebaseDelta = 0;
+      diff.content.forEach(chunk => {
+        if (chunk.ab) { return; }
+        const deltaSize = Math.max(
+            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+        if (chunk.due_to_rebase) {
+          rebaseDelta += deltaSize;
+        } else {
+          nonRebaseDelta += deltaSize;
+        }
+      });
+
+      // Find the percent of the delta from due_to_rebase chunks rounded to two
+      // digits. Diffs with no delta are considered 0%.
+      const totalDelta = rebaseDelta + nonRebaseDelta;
+      const percentRebaseDelta = !totalDelta ? 0 :
+          Math.round(100 * rebaseDelta / totalDelta);
+
+      // Report the due_to_rebase percentage in the "diff" category when
+      // applicable.
+      if (this.patchRange.basePatchNum === 'PARENT') {
+        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+      } else if (percentRebaseDelta === 0) {
+        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+      } else {
+        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+            percentRebaseDelta);
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {!Promise}
+     */
+    _loadDiffAssets(diff) {
+      if (isImageDiff(diff)) {
+        return this._getImages(diff).then(images => {
+          this._baseImage = images.baseImage;
+          this._revisionImage = images.revisionImage;
+        });
+      } else {
+        this._baseImage = null;
+        this._revisionImage = null;
+        return Promise.resolve();
+      }
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {boolean}
+     */
+    _computeIsImageDiff(diff) {
+      return isImageDiff(diff);
+    },
+
+    /**
+     * @param {Object} blame
+     * @return {boolean}
+     */
+    _computeIsBlameLoaded(blame) {
+      return !!blame;
+    },
+
+    /**
+     * @param {Object} diff
+     * @return {!Promise}
+     */
+    _getImages(diff) {
+      return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
+          this.patchRange);
+    },
+
+    _handleDraftInteraction() {
+      this.$.reporting.recordDraftInteraction();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
new file mode 100644
index 0000000..a05d44f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -0,0 +1,779 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-diff-host.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-host></gr-diff-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-host tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('reload() cancels before network resolves', () => {
+      const cancelStub = sandbox.stub(element.$.diff, 'cancel');
+
+      // Stub the network calls into requests that never resolve.
+      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+
+      element.reload();
+      assert.isTrue(cancelStub.called);
+    });
+
+    suite('not logged in', () => {
+      setup(() => {
+        const getLoggedInPromise = Promise.resolve(false);
+        stub('gr-rest-api-interface', {
+          getLoggedIn() { return getLoggedInPromise; },
+        });
+        element = fixture('basic');
+        return getLoggedInPromise;
+      });
+
+      test('reload() loads files weblinks', () => {
+        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+            .returns({name: 'stubb', url: '#s'});
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
+          content: [],
+        }));
+        element.projectName = 'test-project';
+        element.path = 'test-path';
+        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+        element.patchRange = {};
+        return element.reload().then(() => {
+          assert.isTrue(weblinksStub.calledTwice);
+          assert.isTrue(weblinksStub.firstCall.calledWith({
+            commit: 'test-base',
+            file: 'test-path',
+            options: {
+              weblinks: undefined,
+            },
+            repo: 'test-project',
+            type: Gerrit.Nav.WeblinkType.FILE}));
+          assert.isTrue(weblinksStub.secondCall.calledWith({
+            commit: 'test-commit',
+            file: 'test-path',
+            options: {
+              weblinks: undefined,
+            },
+            repo: 'test-project',
+            type: Gerrit.Nav.WeblinkType.FILE}));
+          assert.deepEqual(element.filesWeblinks, {
+            meta_a: [{name: 'stubb', url: '#s'}],
+            meta_b: [{name: 'stubb', url: '#s'}],
+          });
+        });
+      });
+
+      test('_getDiff handles null diff responses', done => {
+        stub('gr-rest-api-interface', {
+          getDiff() { return Promise.resolve(null); },
+        });
+        element.changeNum = 123;
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        element.path = 'file.txt';
+        element._getDiff().then(done);
+      });
+
+      test('reload resolves on error', () => {
+        const onErrStub = sandbox.stub(element, '_handleGetDiffError');
+        const error = {ok: false, status: 500};
+        sandbox.stub(element.$.restAPI, 'getDiff',
+            (changeNum, basePatchNum, patchNum, path, onErr) => {
+              onErr(error);
+            });
+        return element.reload().then(() => {
+          assert.isTrue(onErrStub.calledOnce);
+        });
+      });
+
+      suite('_handleGetDiffError', () => {
+        let serverErrorStub;
+        let pageErrorStub;
+
+        setup(() => {
+          serverErrorStub = sinon.stub();
+          element.addEventListener('server-error', serverErrorStub);
+          pageErrorStub = sinon.stub();
+          element.addEventListener('page-error', pageErrorStub);
+        });
+
+        test('page error on HTTP-409', () => {
+          element._handleGetDiffError({status: 409});
+          assert.isTrue(serverErrorStub.calledOnce);
+          assert.isFalse(pageErrorStub.called);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('server error on non-HTTP-409', () => {
+          element._handleGetDiffError({status: 500});
+          assert.isFalse(serverErrorStub.called);
+          assert.isTrue(pageErrorStub.calledOnce);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('error message if showLoadFailure', () => {
+          element.showLoadFailure = true;
+          element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+          assert.isFalse(serverErrorStub.called);
+          assert.isFalse(pageErrorStub.called);
+          assert.equal(element._errorMessage,
+              'Encountered error when loading the diff: 500 Failure!');
+        });
+      });
+
+      suite('image diffs', () => {
+        let mockFile1;
+        let mockFile2;
+        setup(() => {
+          mockFile1 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+            type: 'image/bmp',
+          };
+          mockFile2 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+            type: 'image/bmp',
+          };
+          sandbox.stub(element.$.restAPI,
+              'getB64FileContents',
+              (changeId, patchNum, path, opt_parentIndex) => {
+                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
+                    mockFile2);
+              });
+
+          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.comments = {left: [], right: []};
+        });
+
+        test('renders image diffs with same file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diff.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diff.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isNotOk(rightLabelName);
+            assert.isNotOk(leftLabelName);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders image diffs with a different file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot2.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot2.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diff.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diff.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isOk(rightLabelName);
+            assert.isOk(leftLabelName);
+            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders added image', done => {
+          const mockDiff = {
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'ADDED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 0000000..f9c2f2c 100644',
+              '--- /dev/null',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+
+            assert.isNotOk(leftImage);
+            assert.isOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders removed image', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            const rightImage =
+                element.$.diff.$.diffTable.querySelector('td.right img');
+
+            assert.isOk(leftImage);
+            assert.isNotOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('does not render disallowed image type', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          mockFile1.type = 'image/jpeg-evil';
+
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            const leftImage =
+                element.$.diff.$.diffTable.querySelector('td.left img');
+            assert.isNotOk(leftImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+      });
+    });
+
+    test('delegates cancel()', () => {
+      const stub = sandbox.stub(element.$.diff, 'cancel');
+      element.reload();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates getCursorStops()', () => {
+      const returnValue = [document.createElement('b')];
+      const stub = sandbox.stub(element.$.diff, 'getCursorStops')
+          .returns(returnValue);
+      assert.equal(element.getCursorStops(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates isRangeSelected()', () => {
+      const returnValue = true;
+      const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
+          .returns(returnValue);
+      assert.equal(element.isRangeSelected(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates toggleLeftDiff()', () => {
+      const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+      element.toggleLeftDiff();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    suite('blame', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      test('clearBlame', () => {
+        element._blame = [];
+        const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
+        element.clearBlame();
+        assert.isNull(element._blame);
+        assert.isTrue(setBlameSpy.calledWithExactly(null));
+        assert.equal(element.isBlameLoaded, false);
+      });
+
+      test('loadBlame', () => {
+        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame().then(() => {
+          assert.isTrue(getBlameStub.calledWithExactly(
+              42, 5, 'foo/bar.baz', true));
+          assert.isFalse(showAlertStub.called);
+          assert.equal(element._blame, mockBlame);
+          assert.equal(element.isBlameLoaded, true);
+        });
+      });
+
+      test('loadBlame empty', () => {
+        const mockBlame = [];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame()
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(() => {
+              assert.isTrue(showAlertStub.calledOnce);
+              assert.isNull(element._blame);
+              assert.equal(element.isBlameLoaded, false);
+            });
+      });
+    });
+
+    test('delegates getThreadEls()', () => {
+      const returnValue = [document.createElement('b')];
+      const stub = sandbox.stub(element.$.diff, 'getThreadEls')
+          .returns(returnValue);
+      assert.equal(element.getThreadEls(), returnValue);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates addDraftAtLine(el)', () => {
+      const param0 = document.createElement('b');
+      const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
+      element.addDraftAtLine(param0);
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 1);
+      assert.equal(stub.lastCall.args[0], param0);
+    });
+
+    test('delegates clearDiffContent()', () => {
+      const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
+      element.clearDiffContent();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('delegates expandAllContext()', () => {
+      const stub = sandbox.stub(element.$.diff, 'expandAllContext');
+      element.expandAllContext();
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.args.length, 0);
+    });
+
+    test('passes in changeNum', () => {
+      const value = '12345';
+      element.changeNum = value;
+      assert.equal(element.$.diff.changeNum, value);
+    });
+
+    test('passes in noAutoRender', () => {
+      const value = true;
+      element.noAutoRender = value;
+      assert.equal(element.$.diff.noAutoRender, value);
+    });
+
+    test('passes in patchRange', () => {
+      const value = {patchNum: 'foo', basePatchNum: 'bar'};
+      element.patchRange = value;
+      assert.equal(element.$.diff.patchRange, value);
+    });
+
+    test('passes in path', () => {
+      const value = 'some/file/path';
+      element.path = value;
+      assert.equal(element.$.diff.path, value);
+    });
+
+    test('passes in prefs', () => {
+      const value = {};
+      element.prefs = value;
+      assert.equal(element.$.diff.prefs, value);
+    });
+
+    test('passes in projectConfig', () => {
+      const value = {};
+      element.projectConfig = value;
+      assert.equal(element.$.diff.projectConfig, value);
+    });
+
+    test('passes in changeNum', () => {
+      const value = '12345';
+      element.changeNum = value;
+      assert.equal(element.$.diff.changeNum, value);
+    });
+
+    test('passes in projectName', () => {
+      const value = 'Gerrit';
+      element.projectName = value;
+      assert.equal(element.$.diff.projectName, value);
+    });
+
+    test('passes in displayLine', () => {
+      const value = true;
+      element.displayLine = value;
+      assert.equal(element.$.diff.displayLine, value);
+    });
+
+    test('passes in commitRange', () => {
+      const value = {};
+      element.commitRange = value;
+      assert.equal(element.$.diff.commitRange, value);
+    });
+
+    test('passes in hidden', () => {
+      const value = true;
+      element.hidden = value;
+      assert.equal(element.$.diff.hidden, value);
+      assert.isNotNull(element.getAttribute('hidden'));
+    });
+
+    test('passes in noRenderOnPrefsChange', () => {
+      const value = true;
+      element.noRenderOnPrefsChange = value;
+      assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+    });
+
+    test('passes in comments', () => {
+      const value = {left: [], right: []};
+      element.comments = value;
+      assert.equal(element.$.diff.comments, value);
+    });
+
+    test('passes in lineWrapping', () => {
+      const value = true;
+      element.lineWrapping = value;
+      assert.equal(element.$.diff.lineWrapping, value);
+    });
+
+    test('passes in viewMode', () => {
+      const value = 'SIDE_BY_SIDE';
+      element.viewMode = value;
+      assert.equal(element.$.diff.viewMode, value);
+    });
+
+    test('passes in lineOfInterest', () => {
+      const value = {number: 123, leftSide: true};
+      element.lineOfInterest = value;
+      assert.equal(element.$.diff.lineOfInterest, value);
+    });
+
+    suite('_reportDiff', () => {
+      let reportStub;
+
+      setup(() => {
+        element = fixture('basic');
+        element.patchRange = {basePatchNum: 1};
+        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
+      });
+
+      test('null and content-less', () => {
+        element._reportDiff(null);
+        assert.isFalse(reportStub.called);
+
+        element._reportDiff({});
+        assert.isFalse(reportStub.called);
+      });
+
+      test('diff w/ no delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {ab: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+
+      test('diff w/ no rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+
+      test('diff w/ some rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
+        assert.strictEqual(reportStub.lastCall.args[1], 50);
+      });
+
+      test('diff w/ all rebase delta', () => {
+        const diff = {content: [{
+          a: ['foo', 'bar'],
+          b: ['baz', 'foo'],
+          due_to_rebase: true,
+        }]};
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
+        assert.strictEqual(reportStub.lastCall.args[1], 100);
+      });
+
+      test('diff against parent event', () => {
+        element.patchRange.basePatchNum = 'PARENT';
+        const diff = {content: [{
+          a: ['foo', 'bar'],
+          b: ['baz', 'foo'],
+        }]};
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+        assert.isUndefined(reportStub.lastCall.args[1]);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 1b5203e..2e56871 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -36,7 +36,7 @@
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-mode-selector/gr-diff-mode-selector.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-diff-host/gr-diff-host.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
 
 <dom-module id="gr-diff-view">
@@ -320,8 +320,8 @@
       </div>
     </gr-fixed-panel>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <gr-diff
-        id="diff"
+    <gr-diff-host
+        id="diffHost"
         hidden
         hidden$="[[_loading]]"
         class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
@@ -337,7 +337,7 @@
         view-mode="[[_diffMode]]"
         is-blame-loaded="{{_isBlameLoaded}}"
         on-line-selected="_onLineSelected">
-    </gr-diff>
+    </gr-diff-host>
     <gr-diff-preferences
         id="diffPreferences"
         prefs="{{_prefs}}"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 8798a8f..b0eb423 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -200,7 +200,7 @@
         this._loggedIn = loggedIn;
       });
 
-      this.$.cursor.push('diffs', this.$.diff);
+      this.$.cursor.push('diffs', this.$.diffHost);
     },
 
     _getLoggedIn() {
@@ -276,7 +276,7 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = false;
+      this.$.diffHost.displayLine = false;
     },
 
     _handleShiftLeftKey(e) {
@@ -303,7 +303,7 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveUp();
     },
 
@@ -317,7 +317,7 @@
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this.$.diff.displayLine = true;
+      this.$.diffHost.displayLine = true;
       this.$.cursor.moveDown();
     },
 
@@ -350,13 +350,13 @@
 
     _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (this.$.diff.isRangeSelected()) { return; }
+      if (this.$.diffHost.isRangeSelected()) { return; }
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
       const line = this.$.cursor.getTargetLineElement();
       if (line) {
-        this.$.diff.addDraftAtLine(line);
+        this.$.diffHost.addDraftAtLine(line);
       }
     },
 
@@ -407,7 +407,7 @@
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
         e.preventDefault();
-        this.$.diff.toggleLeftDiff();
+        this.$.diffHost.toggleLeftDiff();
         return;
       }
 
@@ -549,7 +549,7 @@
         this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
       }
 
-      this.$.diff.lineOfInterest = this._getLineOfInterest(this.params);
+      this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
       this._initCursor(this.params);
 
       this._changeNum = value.changeNum;
@@ -621,8 +621,8 @@
           });
         }
         this._loading = false;
-        this.$.diff.comments = this._commentsForDiff;
-        return this.$.diff.reload();
+        this.$.diffHost.comments = this._commentsForDiff;
+        return this.$.diffHost.reload();
       }).then(() => {
         this.$.reporting.diffViewDisplayed();
       });
@@ -949,13 +949,13 @@
      */
     _toggleBlame() {
       if (this._isBlameLoaded) {
-        this.$.diff.clearBlame();
+        this.$.diffHost.clearBlame();
         return;
       }
 
       this._isBlameLoading = true;
       this.fire('show-alert', {message: MSG_LOADING_BLAME});
-      this.$.diff.loadBlame()
+      this.$.diffHost.loadBlame()
           .then(() => {
             this._isBlameLoading = false;
             this.fire('show-alert', {message: MSG_LOADED_BLAME});
@@ -991,7 +991,7 @@
 
     _handleShiftXKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.diff.expandAllContext();
+      this.$.diffHost.expandAllContext();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index f947d18..00527e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -73,7 +73,7 @@
 
     test('params change triggers diffViewDisplayed()', () => {
       sandbox.stub(element.$.reporting, 'diffViewDisplayed');
-      sandbox.stub(element.$.diff, 'reload').returns(Promise.resolve());
+      sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sandbox.spy(element, '_paramsChanged');
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -89,7 +89,8 @@
     });
 
     test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+      const toggleLeftDiffStub = sandbox.stub(
+          element.$.diffHost, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
@@ -168,7 +169,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
-      const computeContainerClassStub = sandbox.stub(element.$.diff,
+      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
@@ -189,7 +190,7 @@
     });
 
     test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sandbox.stub(element.$.diff, 'expandAllContext');
+      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
       MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
       flushAsynchronousOperations();
       assert.isTrue(expandStub.called);
@@ -550,7 +551,7 @@
       const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
           () => Promise.resolve());
 
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -575,7 +576,7 @@
     test('file review status', () => {
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
 
       element._loggedIn = true;
       element.params = {
@@ -621,7 +622,7 @@
     });
 
     test('hash is determined from params', done => {
-      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element.$.diffHost, 'reload');
       sandbox.stub(element, '_initCursor');
 
       element._loggedIn = true;
@@ -642,7 +643,7 @@
 
     test('diff mode selector correctly toggles the diff', () => {
       const select = element.$.modeSelect;
-      const diffDisplay = element.$.diff;
+      const diffDisplay = element.$.diffHost;
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -687,7 +688,7 @@
 
     suite('_commitRange', () => {
       setup(() => {
-        sandbox.stub(element.$.diff, 'reload');
+        sandbox.stub(element.$.diffHost, 'reload');
         sandbox.stub(element, '_initCursor');
         sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
           _number: 42,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 8e975f4..6add75c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -18,9 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
@@ -55,7 +53,6 @@
         background-color: var(--table-header-background-color);
       }
       .image-diff .gr-diff {
-        background-color: var(--table-header-background-color);
         text-align: center;
       }
       .image-diff img {
@@ -83,6 +80,9 @@
       .content {
         background-color: var(--view-background-color);
       }
+      .image-diff .content {
+        background-color: var(--table-header-background-color);
+      }
       .full-width {
         width: 100%;
       }
@@ -192,15 +192,20 @@
         font-size: var(--font-size, var(--font-size-normal));
         padding: 0.5em 0 0.5em 4em;
       }
+      #loadingError,
       #sizeWarning {
         display: none;
         margin: 1em auto;
         max-width: 60em;
         text-align: center;
       }
+      #loadingError {
+        color: var(--error-text-color);
+      }
       #sizeWarning gr-button {
         margin: 1em;
       }
+      #loadingError.showError,
       #sizeWarning.warn {
         display: block;
       }
@@ -265,26 +270,26 @@
         <div>[[item]]</div>
       </template>
     </div>
-    <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
+    <div class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
-      <gr-diff-selection diff="[[_diff]]">
+      <gr-diff-selection diff="[[diff]]">
         <gr-diff-highlight
             id="highlights"
-            logged-in="[[_loggedIn]]"
+            logged-in="[[loggedIn]]"
             comments="{{comments}}">
           <gr-diff-builder
               id="diffBuilder"
               comments="[[comments]]"
               project-name="[[projectName]]"
-              diff="[[_diff]]"
+              diff="[[diff]]"
               diff-path="[[path]]"
               change-num="[[changeNum]]"
               patch-num="[[patchRange.patchNum]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
-              base-image="[[_baseImage]]"
-              revision-image="[[_revisionImage]]"
+              base-image="[[baseImage]]"
+              revision-image="[[revisionImage]]"
               parent-index="[[_parentIndex]]"
               create-comment-fn="[[_createThreadGroupFn]]"
               line-of-interest="[[lineOfInterest]]">
@@ -296,13 +301,16 @@
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
-    <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
+    <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
       [[_newlineWarning]]
     </div>
+    <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+      [[errorMessage]]
+    </div>
     <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
-        large (about [[_diffLength(_diff)]] lines).
+        large (about [[_diffLength(diff)]] lines).
       </p>
       <gr-button on-tap="_handleLimitedBypass">
         Render with limited context
@@ -311,8 +319,6 @@
         Render anyway (may be slow)
       </gr-button>
     </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
   <script src="gr-diff-line.js"></script>
   <script src="gr-diff-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 4b19e67..9f1b412 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -21,11 +21,6 @@
   const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
       'of an edit.';
   const ERR_INVALID_LINE = 'Invalid line number: ';
-  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
-
-  const EVENT_AGAINST_PARENT = 'diff-against-parent';
-  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
   const NO_NEWLINE_BASE = 'No newline at end of base file.';
   const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -64,6 +59,12 @@
      * @event diff-comments-modified
      */
 
+     /**
+      * Fired when a draft is added or edited.
+      *
+      * @event draft-interaction
+      */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -88,21 +89,17 @@
       },
       isImageDiff: {
         type: Boolean,
-        computed: '_computeIsImageDiff(_diff)',
-        notify: true,
       },
       commitRange: Object,
-      filesWeblinks: {
-        type: Object,
-        value() { return {}; },
-        notify: true,
-      },
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
       },
       noRenderOnPrefsChange: Boolean,
-      comments: Object,
+      comments: {
+        type: Object,
+        value: {left: [], right: []},
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -123,29 +120,33 @@
        */
       lineOfInterest: Object,
 
-      _loading: {
+      loading: {
         type: Boolean,
         value: false,
+        observer: '_loadingChanged',
       },
 
-      _loggedIn: {
+      loggedIn: {
         type: Boolean,
         value: false,
       },
-      _diff: Object,
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
       _diffHeaderItems: {
         type: Array,
         value: [],
-        computed: '_computeDiffHeaderItems(_diff.*)',
+        computed: '_computeDiffHeaderItems(diff.*)',
       },
       _diffTableClass: {
         type: String,
         value: '',
       },
       /** @type {?Object} */
-      _baseImage: Object,
+      baseImage: Object,
       /** @type {?Object} */
-      _revisionImage: Object,
+      revisionImage: Object,
 
       /**
        * Whether the safety check for large diffs when whole-file is set has
@@ -162,15 +163,17 @@
 
       _showWarning: Boolean,
 
-      /** @type {?Object} */
-      _blame: {
-        type: Object,
+      /** @type {?string} */
+      errorMessage: {
+        type: String,
         value: null,
       },
-      isBlameLoaded: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeIsBlameLoaded(_blame)',
+
+      /** @type {?Object} */
+      blame: {
+        type: Object,
+        value: null,
+        observer: '_blameChanged',
       },
 
       _parentIndex: {
@@ -180,7 +183,7 @@
 
       _newlineWarning: {
         type: String,
-        computed: '_computeNewlineWarning(_diff)',
+        computed: '_computeNewlineWarning(diff)',
       },
 
       /**
@@ -205,44 +208,6 @@
       'create-comment': '_handleCreateComment',
     },
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
-
-    ready() {
-      if (this._canRender()) {
-        this.reload();
-      }
-    },
-
-    /** @return {!Promise} */
-    reload() {
-      this._loading = true;
-      this.cancel();
-      this.clearBlame();
-      this._safetyBypass = null;
-      this._showWarning = false;
-      this.clearDiffContent();
-
-      const promises = [];
-
-      promises.push(this._getDiff().then(diff => {
-        this._diff = diff;
-        return this._loadDiffAssets();
-      }));
-
-      return Promise.all(promises).then(() => {
-        if (this.prefs) {
-          return this._renderDiffTable();
-        }
-        return Promise.resolve();
-      }).then(() => {
-        this._loading = false;
-      });
-    },
-
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
@@ -266,37 +231,13 @@
       this.toggleClass('no-left');
     },
 
-    /**
-     * Load and display blame information for the base of the diff.
-     * @return {Promise} A promise that resolves when blame finishes rendering.
-     */
-    loadBlame() {
-      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
-          this.path, true)
-          .then(blame => {
-            if (!blame.length) {
-              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
-              return Promise.reject(MSG_EMPTY_BLAME);
-            }
-
-            this._blame = blame;
-
-            this.$.diffBuilder.setBlame(blame);
-            this.classList.add('showBlame');
-          });
-    },
-
-    _computeIsBlameLoaded(blame) {
-      return !!blame;
-    },
-
-    /**
-     * Unload blame information for the diff.
-     */
-    clearBlame() {
-      this._blame = null;
-      this.$.diffBuilder.setBlame(null);
-      this.classList.remove('showBlame');
+    _blameChanged(newValue) {
+      this.$.diffBuilder.setBlame(newValue);
+      if (newValue) {
+        this.classList.add('showBlame');
+      } else {
+        this.classList.remove('showBlame');
+      }
     },
 
     _handleCommentSaveOrDiscard() {
@@ -304,12 +245,6 @@
           {bubbles: true}));
     },
 
-    /** @return {boolean}} */
-    _canRender() {
-      return !!this.changeNum && !!this.patchRange && !!this.path &&
-          !this.noAutoRender;
-    },
-
     /** @return {!Array<!HTMLElement>} */
     getThreadEls() {
       let threads = [];
@@ -371,20 +306,18 @@
 
     addDraftAtLine(el) {
       this._selectLine(el);
-      this._isValidElForComment(el).then(valid => {
-        if (!valid) { return; }
+      if (!this._isValidElForComment(el)) { return; }
 
-        const value = el.getAttribute('data-value');
-        let lineNum;
-        if (value !== GrDiffLine.FILE) {
-          lineNum = parseInt(value, 10);
-          if (isNaN(lineNum)) {
-            this.fire('show-alert', {message: ERR_INVALID_LINE + value});
-            return;
-          }
+      const value = el.getAttribute('data-value');
+      let lineNum;
+      if (value !== GrDiffLine.FILE) {
+        lineNum = parseInt(value, 10);
+        if (isNaN(lineNum)) {
+          this.fire('show-alert', {message: ERR_INVALID_LINE + value});
+          return;
         }
-        this._createComment(el, lineNum);
-      });
+      }
+      this._createComment(el, lineNum);
     },
 
     _handleCreateComment(e) {
@@ -392,36 +325,34 @@
       const side = e.detail.side;
       const lineNum = range.endLine;
       const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-      this._isValidElForComment(lineEl).then(valid => {
-        if (!valid) { return; }
 
+      if (this._isValidElForComment(lineEl)) {
         this._createComment(lineEl, lineNum, side, range);
-      });
+      }
     },
 
+    /** @return {boolean} */
     _isValidElForComment(el) {
-      return this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          this.fire('show-auth-required');
-          return false;
-        }
-        const patchNum = el.classList.contains(DiffSide.LEFT) ?
-            this.patchRange.basePatchNum :
-            this.patchRange.patchNum;
+      if (!this.loggedIn) {
+        this.fire('show-auth-required');
+        return false;
+      }
+      const patchNum = el.classList.contains(DiffSide.LEFT) ?
+          this.patchRange.basePatchNum :
+          this.patchRange.patchNum;
 
-        const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
-        const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
-            this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+      const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
+      const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
+          this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
 
-        if (isEdit) {
-          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
-          return false;
-        } else if (isEditBase) {
-          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
-          return false;
-        }
-        return true;
-      });
+      if (isEdit) {
+        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
+        return false;
+      } else if (isEditBase) {
+        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
+        return false;
+      }
+      return true;
     },
 
     /**
@@ -431,7 +362,7 @@
      * @param {!Object=} opt_range
      */
     _createComment(lineEl, opt_lineNum, opt_side, opt_range) {
-      this.$.reporting.recordDraftInteraction();
+      this.dispatchEvent(new CustomEvent('draft-interaction', {bubbles: true}));
       const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       const contentEl = contentText.parentElement;
       const side = opt_side ||
@@ -462,20 +393,6 @@
     },
 
     /**
-     * @param {string} commentSide
-     * @param {!Object=} opt_range
-     */
-    _getRangeString(commentSide, opt_range) {
-      return opt_range ?
-        'range-' +
-        opt_range.startLine + '-' +
-        opt_range.startChar + '-' +
-        opt_range.endLine + '-' +
-        opt_range.endChar + '-' +
-        commentSide : 'line-' + commentSide;
-    },
-
-    /**
      * Gets or creates a comment thread for a specific spot on a diff.
      * May include a range, if the comment is a range comment.
      *
@@ -661,6 +578,17 @@
       this._prefsChanged(this.prefs);
     },
 
+    /** @param {boolean} newValue */
+    _loadingChanged(newValue) {
+      if (newValue) {
+        this.cancel();
+        this._blame = null;
+        this._safetyBypass = null;
+        this._showWarning = false;
+        this.clearDiffContent();
+      }
+    },
+
     _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
     },
@@ -668,7 +596,7 @@
     _prefsChanged(prefs) {
       if (!prefs) { return; }
 
-      this.clearBlame();
+      this._blame = null;
 
       const stylesToUpdate = {};
 
@@ -689,21 +617,32 @@
 
       this.updateStyles(stylesToUpdate);
 
-      if (this._diff && this.comments && !this.noRenderOnPrefsChange) {
+      if (this.diff && this.comments && !this.noRenderOnPrefsChange) {
+        this._renderDiffTable();
+      }
+    },
+
+    _diffChanged(newValue) {
+      if (newValue) {
         this._renderDiffTable();
       }
     },
 
     _renderDiffTable() {
+      if (!this.prefs) {
+        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        return;
+      }
       if (this.prefs.context === -1 &&
-          this._diffLength(this._diff) >= LARGE_DIFF_THRESHOLD_LINES &&
+          this._diffLength(this.diff) >= LARGE_DIFF_THRESHOLD_LINES &&
           this._safetyBypass === null) {
         this._showWarning = true;
-        return Promise.resolve();
+        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        return;
       }
 
       this._showWarning = false;
-      return this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
+      this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
     /**
@@ -720,117 +659,6 @@
       this.$.diffTable.innerHTML = null;
     },
 
-    _handleGetDiffError(response) {
-      // Loading the diff may respond with 409 if the file is too large. In this
-      // case, use a toast error..
-      if (response.status === 409) {
-        this.fire('server-error', {response});
-        return;
-      }
-      this.fire('page-error', {response});
-    },
-
-    /** @return {!Promise<!Object>} */
-    _getDiff() {
-      return this.$.restAPI.getDiff(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path,
-          this._handleGetDiffError.bind(this)).then(diff => {
-            this._reportDiff(diff);
-            if (!this.commitRange) {
-              this.filesWeblinks = {};
-              return diff;
-            }
-            this.filesWeblinks = {
-              meta_a: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.baseCommit, this.path,
-                  {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
-              meta_b: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.commit, this.path,
-                  {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
-            };
-            return diff;
-          });
-    },
-
-    /**
-     * Report info about the diff response.
-     */
-    _reportDiff(diff) {
-      if (!diff || !diff.content) { return; }
-
-      // Count the delta lines stemming from normal deltas, and from
-      // due_to_rebase deltas.
-      let nonRebaseDelta = 0;
-      let rebaseDelta = 0;
-      diff.content.forEach(chunk => {
-        if (chunk.ab) { return; }
-        const deltaSize = Math.max(
-            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
-        if (chunk.due_to_rebase) {
-          rebaseDelta += deltaSize;
-        } else {
-          nonRebaseDelta += deltaSize;
-        }
-      });
-
-      // Find the percent of the delta from due_to_rebase chunks rounded to two
-      // digits. Diffs with no delta are considered 0%.
-      const totalDelta = rebaseDelta + nonRebaseDelta;
-      const percentRebaseDelta = !totalDelta ? 0 :
-          Math.round(100 * rebaseDelta / totalDelta);
-
-      // Report the due_to_rebase percentage in the "diff" category when
-      // applicable.
-      if (this.patchRange.basePatchNum === 'PARENT') {
-        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
-      } else if (percentRebaseDelta === 0) {
-        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
-      } else {
-        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
-            percentRebaseDelta);
-      }
-    },
-
-    /** @return {!Promise} */
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    /** @return {boolean} */
-    _computeIsImageDiff() {
-      if (!this._diff) { return false; }
-
-      const isA = this._diff.meta_a &&
-          this._diff.meta_a.content_type.startsWith('image/');
-      const isB = this._diff.meta_b &&
-          this._diff.meta_b.content_type.startsWith('image/');
-
-      return !!(this._diff.binary && (isA || isB));
-    },
-
-    /** @return {!Promise} */
-    _loadDiffAssets() {
-      if (this.isImageDiff) {
-        return this._getImages().then(images => {
-          this._baseImage = images.baseImage;
-          this._revisionImage = images.revisionImage;
-        });
-      } else {
-        this._baseImage = null;
-        this._revisionImage = null;
-        return Promise.resolve();
-      }
-    },
-
-    /** @return {!Promise} */
-    _getImages() {
-      return this.$.restAPI.getImagesForDiff(this.changeNum, this._diff,
-          this.patchRange);
-    },
-
     _projectConfigChanged(projectConfig) {
       const threadEls = this.getThreadEls();
       for (let i = 0; i < threadEls.length; i++) {
@@ -891,6 +719,14 @@
     },
 
     /**
+     * @param {string} errorMessage
+     * @return {string}
+     */
+    _computeErrorClass(errorMessage) {
+      return errorMessage ? 'showError' : '';
+    },
+
+    /**
      * @return {number|null}
      */
     _computeParentIndex(patchRangeRecord) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index b98602f..0274fae 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -48,18 +48,8 @@
       sandbox.restore();
     });
 
-    test('reload cancels before network resolves', () => {
-      element = fixture('basic');
-      const cancelStub = sandbox.stub(element, 'cancel');
-
-      // Stub the network calls into requests that never resolve.
-      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
-
-      element.reload();
-      assert.isTrue(cancelStub.called);
-    });
-
     test('cancel', () => {
+      element = fixture('basic');
       const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
       element.cancel();
       assert.isTrue(cancelStub.calledOnce);
@@ -172,10 +162,12 @@
 
     suite('not logged in', () => {
       setup(() => {
+        const getLoggedInPromise = Promise.resolve(false);
         stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(false); },
+          getLoggedIn() { return getLoggedInPromise; },
         });
         element = fixture('basic');
+        return getLoggedInPromise;
       });
 
       test('toggleLeftDiff', () => {
@@ -185,15 +177,12 @@
         assert.isFalse(element.classList.contains('no-left'));
       });
 
-      test('addDraftAtLine', done => {
+      test('addDraftAtLine', () => {
         sandbox.stub(element, '_selectLine');
         const loggedInErrorSpy = sandbox.spy();
         element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addDraftAtLine();
-        flush(() => {
-          assert.isTrue(loggedInErrorSpy.called);
-          done();
-        });
+        assert.isTrue(loggedInErrorSpy.called);
       });
 
       test('view does not start with displayLine classList', () => {
@@ -209,39 +198,6 @@
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('loads files weblinks', () => {
-        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-            .returns({name: 'stubb', url: '#s'});
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({}));
-        element.projectName = 'test-project';
-        element.path = 'test-path';
-        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-        element.patchRange = {};
-        return element._getDiff().then(() => {
-          assert.isTrue(weblinksStub.calledTwice);
-          assert.isTrue(weblinksStub.firstCall.calledWith({
-            commit: 'test-base',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.isTrue(weblinksStub.secondCall.calledWith({
-            commit: 'test-commit',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.deepEqual(element.filesWeblinks, {
-            meta_a: [{name: 'stubb', url: '#s'}],
-            meta_b: [{name: 'stubb', url: '#s'}],
-          });
-        });
-      });
-
       test('remove comment', () => {
         element.comments = {
           meta: {
@@ -343,20 +299,6 @@
         });
       });
 
-      test('_getRangeString', () => {
-        const side = 'PARENT';
-        const range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 2,
-        };
-        assert.equal(element._getRangeString(side, range),
-            'range-1-1-1-2-PARENT');
-        assert.equal(element._getRangeString(side, null),
-            'line-PARENT');
-      }),
-
       test('thread groups', () => {
         const contentEl = document.createElement('div');
         const commentSide = 'left';
@@ -410,7 +352,6 @@
       suite('image diffs', () => {
         let mockFile1;
         let mockFile2;
-        const stubs = [];
         setup(() => {
           mockFile1 = {
             body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
@@ -422,66 +363,29 @@
             'wsAAAAAAAAAAAAA/////w==',
             type: 'image/bmp',
           };
-          const mockCommit = {
-            commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
-            parents: [{
-              commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
-              subject: 'Added a carrot',
-            }],
-            author: {
-              name: 'Wyatt Allen',
-              email: 'wyatta@google.com',
-              date: '2016-05-23 21:44:51.000000000',
-              tz: -420,
-            },
-            committer: {
-              name: 'Wyatt Allen',
-              email: 'wyatta@google.com',
-              date: '2016-05-25 00:25:41.000000000',
-              tz: -420,
-            },
-            subject: 'Updated the carrot',
-            message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
-          };
-          const mockComments = {baseComments: [], comments: []};
-
-          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-              () => Promise.resolve(mockCommit)));
-          stubs.push(sandbox.stub(element.$.restAPI,
-              'getB64FileContents',
-              (changeId, patchNum, path, opt_parentIndex) => {
-                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
-                    mockFile2);
-              }));
-          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-              () => Promise.resolve(mockComments)));
-          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-              () => Promise.resolve(mockComments)));
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
           element.comments = {left: [], right: []};
+          element.isImageDiff = true;
+          element.prefs = {
+            auto_hide_diff_table_header: true,
+            context: 10,
+            cursor_blink_rate: 0,
+            font_size: 12,
+            ignore_whitespace: 'IGNORE_NONE',
+            intraline_difference: true,
+            line_length: 100,
+            line_wrapping: false,
+            show_line_endings: true,
+            show_tabs: true,
+            show_whitespace_errors: true,
+            syntax_highlighting: true,
+            tab_size: 8,
+            theme: 'DEFAULT',
+          };
         });
 
         test('renders image diffs with same file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
-
           const rendered = () => {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
@@ -536,10 +440,24 @@
 
           element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.revisionImage = mockFile2;
+          element.diff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
         });
 
         test('renders image diffs with a different file name', done => {
@@ -559,8 +477,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           const rendered = () => {
             // Recognizes that it should be an image diff.
@@ -618,10 +534,11 @@
 
           element.addEventListener('render', rendered);
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.baseImage._name = mockDiff.meta_a.name;
+          element.revisionImage = mockFile2;
+          element.revisionImage._name = mockDiff.meta_b.name;
+          element.diff = mockDiff;
         });
 
         test('renders added image', done => {
@@ -640,8 +557,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -657,10 +572,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.revisionImage = mockFile2;
+          element.diff = mockDiff;
         });
 
         test('renders removed image', done => {
@@ -679,8 +592,6 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -696,10 +607,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.diff = mockDiff;
         });
 
         test('does not render disallowed image type', done => {
@@ -720,9 +629,6 @@
           };
           mockFile1.type = 'image/jpeg-evil';
 
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
-
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
@@ -733,10 +639,8 @@
             done();
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
+          element.baseImage = mockFile1;
+          element.diff = mockDiff;
         });
       });
 
@@ -783,20 +687,10 @@
         content.click();
       });
 
-      test('_getDiff handles null diff responses', done => {
-        stub('gr-rest-api-interface', {
-          getDiff() { return Promise.resolve(null); },
-        });
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-        element._getDiff().then(done);
-      });
-
       suite('getCursorStops', () => {
         const setupDiff = function() {
           const mock = document.createElement('mock-diff-response');
-          element._diff = mock.diffResponse;
+          element.diff = mock.diffResponse;
           element.comments = {
             left: [],
             right: [],
@@ -845,14 +739,8 @@
     suite('logged in', () => {
       let fakeLineEl;
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getPreferences() {
-            return Promise.resolve({time_format: 'HHMM_12'});
-          },
-          getAccountCapabilities() { return Promise.resolve(); },
-        });
         element = fixture('basic');
+        element.loggedIn = true;
         element.patchRange = {};
 
         fakeLineEl = {
@@ -863,58 +751,40 @@
         };
       });
 
-      test('addDraftAtLine', done => {
+      test('addDraftAtLine', () => {
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(element._createComment
-              .calledWithExactly(fakeLineEl, 42));
-          done();
-        });
+        assert.isTrue(element._createComment
+            .calledWithExactly(fakeLineEl, 42));
       });
 
-      test('addDraftAtLine on an edit', done => {
+      test('addDraftAtLine on an edit', () => {
         element.patchRange.basePatchNum = element.EDIT_NAME;
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
         const alertSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addEventListener('show-alert', alertSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(alertSpy.called);
-          assert.isFalse(element._createComment.called);
-          done();
-        });
+        assert.isTrue(alertSpy.called);
+        assert.isFalse(element._createComment.called);
       });
 
-      test('addDraftAtLine on an edit base', done => {
+      test('addDraftAtLine on an edit base', () => {
         element.patchRange.patchNum = element.EDIT_NAME;
         element.patchRange.basePatchNum = element.PARENT_NAME;
         sandbox.stub(element, '_selectLine');
         sandbox.stub(element, '_createComment');
-        const loggedInErrorSpy = sandbox.spy();
         const alertSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
         element.addEventListener('show-alert', alertSpy);
         element.addDraftAtLine(fakeLineEl);
-        flush(() => {
-          assert.isFalse(loggedInErrorSpy.called);
-          assert.isTrue(alertSpy.called);
-          assert.isFalse(element._createComment.called);
-          done();
-        });
+        assert.isTrue(alertSpy.called);
+        assert.isFalse(element._createComment.called);
       });
 
       suite('change in preferences', () => {
         setup(() => {
-          element._diff = {
+          element.diff = {
             meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
             meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
               lines: 560},
@@ -1048,7 +918,8 @@
 
     suite('diff header', () => {
       setup(() => {
-        element._diff = {
+        element = fixture('basic');
+        element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
           meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
             lines: 560},
@@ -1061,15 +932,15 @@
 
       test('hidden', () => {
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '--- a/test.jpg');
+        element.push('diff.diff_header', '--- a/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', '+++ b/test.jpg');
+        element.push('diff.diff_header', '+++ b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'test');
+        element.push('diff.diff_header', 'test');
         assert.equal(element._diffHeaderItems.length, 1);
         flushAsynchronousOperations();
 
@@ -1077,13 +948,13 @@
       });
 
       test('binary files', () => {
-        element._diff.binary = true;
+        element.diff.binary = true;
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
         assert.equal(element._diffHeaderItems.length, 0);
-        element.push('_diff.diff_header', 'test');
+        element.push('diff.diff_header', 'test');
         assert.equal(element._diffHeaderItems.length, 1);
-        element.push('_diff.diff_header', 'Binary files differ');
+        element.push('diff.diff_header', 'Binary files differ');
         assert.equal(element._diffHeaderItems.length, 1);
       });
     });
@@ -1094,39 +965,49 @@
       setup(() => {
         element = fixture('basic');
         renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-            () => Promise.resolve());
+            () => {
+              Promise.resolve();
+              element.$.diffBuilder.dispatchEvent(
+                  new CustomEvent('render', {bubbles: true}));
+            });
         const mock = document.createElement('mock-diff-response');
-        element._diff = mock.diffResponse;
+        element.diff = mock.diffResponse;
         element.comments = {left: [], right: []};
         element.noRenderOnPrefsChange = true;
       });
 
-      test('lage render w/ context = 10', () => {
+      test('large render w/ context = 10', done => {
         element.prefs = {context: 10};
         sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and bypass', () => {
+      test('large render w/ whole file and bypass', done => {
         element.prefs = {context: -1};
         element._safetyBypass = 10;
         sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
 
-      test('lage render w/ whole file and no bypass', () => {
+      test('large render w/ whole file and no bypass', done => {
         element.prefs = {context: -1};
         sandbox.stub(element, '_diffLength', () => 10000);
-        return element._renderDiffTable().then(() => {
+        element.addEventListener('render', () => {
           assert.isFalse(renderStub.called);
           assert.isTrue(element._showWarning);
+          done();
         });
+        element._renderDiffTable();
       });
     });
 
@@ -1135,144 +1016,19 @@
         element = fixture('basic');
       });
 
-      test('clearBlame', () => {
-        element._blame = [];
+      test('unsetting', () => {
+        element.blame = [];
         const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
         element.classList.add('showBlame');
-        element.clearBlame();
-        assert.isNull(element._blame);
+        element.blame = null;
         assert.isTrue(setBlameSpy.calledWithExactly(null));
         assert.isFalse(element.classList.contains('showBlame'));
       });
 
-      test('loadBlame', () => {
+      test('setting', () => {
         const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame().then(() => {
-          assert.isTrue(getBlameStub.calledWithExactly(
-              42, 5, 'foo/bar.baz', true));
-          assert.isFalse(showAlertStub.called);
-          assert.equal(element._blame, mockBlame);
-          assert.isTrue(element.classList.contains('showBlame'));
-        });
-      });
-
-      test('loadBlame empty', () => {
-        const mockBlame = [];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame()
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(() => {
-              assert.isTrue(showAlertStub.calledOnce);
-              assert.isNull(element._blame);
-              assert.isFalse(element.classList.contains('showBlame'));
-            });
-      });
-    });
-
-    suite('_reportDiff', () => {
-      let reportStub;
-
-      setup(() => {
-        element = fixture('basic');
-        element.patchRange = {basePatchNum: 1};
-        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-      });
-
-      test('null and content-less', () => {
-        element._reportDiff(null);
-        assert.isFalse(reportStub.called);
-
-        element._reportDiff({});
-        assert.isFalse(reportStub.called);
-      });
-
-      test('diff w/ no delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {ab: ['baz', 'foo']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
-      });
-
-      test('diff w/ no rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
-      });
-
-      test('diff w/ some rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-          ],
-        };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 50);
-      });
-
-      test('diff w/ all rebase delta', () => {
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-          due_to_rebase: true,
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 100);
-      });
-
-      test('diff against parent event', () => {
-        element.patchRange.basePatchNum = 'PARENT';
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        element.blame = mockBlame;
+        assert.isTrue(element.classList.contains('showBlame'));
       });
     });
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
index c2d6cdf..f80e9f8 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -51,7 +51,6 @@
       }
       header gr-editable-label {
         font-size: var(--font-size-large);
-        font-weight: bold;
         --label-style: {
           text-overflow: initial;
           white-space: initial;
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index b4aec99..0508608 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -128,6 +128,10 @@
       });
       this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
+
+        if (config && config.gerrit && config.gerrit.report_bug_url) {
+          this._feedbackUrl = config.gerrit.report_bug_url;
+        }
       });
       this.$.restAPI.getVersion().then(version => {
         this._version = version;
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 b75ae44..7476637 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -40,7 +40,7 @@
           this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins)
           .map(p => this._urlFor(p))
           .filter(p => !Gerrit._isPluginPreloaded(p));
-      const defaultTheme = config.default_theme;
+      const defaultTheme = this._urlFor(config.default_theme);
       const pluginsPending =
           [].concat(jsPlugins, htmlPlugins, defaultTheme || []);
       Gerrit._setPluginsPending(pluginsPending);
@@ -98,6 +98,9 @@
     },
 
     _urlFor(pathOrUrl) {
+      if (!pathOrUrl) {
+        return pathOrUrl;
+      }
       if (pathOrUrl.startsWith('preloaded:') ||
           pathOrUrl.startsWith('http')) {
         // Plugins are loaded from another domain or preloaded.
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 641a800..26958ee 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -188,6 +188,15 @@
       assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
 
+    test('default theme is loaded with html plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      element.config = {
+        default_theme: '/oof',
+        plugin: {},
+      };
+      assert.isTrue(Gerrit._setPluginsPending.calledWith([url + '/oof']));
+    });
+
     test('skips preloaded plugins', () => {
       sandbox.stub(Gerrit, '_isPluginPreloaded')
           .withArgs(url + '/plugins/foo/bar').returns(true)
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index fa188d7..4f69513 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -37,6 +37,9 @@
         cursor: pointer;
         text-align: center;
       }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
       .checkboxContainer:hover {
         outline: 1px solid var(--border-color);
       }
@@ -52,12 +55,12 @@
         <tbody>
           <tr>
             <td>Number</td>
-            <td
-                class="checkboxContainer"
-                on-tap="_handleTargetTap">
+            <td class="checkboxContainer"
+                on-tap="_handleCheckboxContainerTap">
               <input
                   type="checkbox"
                   name="number"
+                  on-tap="_handleNumberCheckboxTap"
                   checked$="[[showNumber]]">
             </td>
           </tr>
@@ -65,10 +68,11 @@
             <tr>
               <td>[[item]]</td>
               <td class="checkboxContainer"
-                  on-tap="_handleTargetTap">
+                  on-tap="_handleCheckboxContainerTap">
                 <input
                     type="checkbox"
                     name="[[item]]"
+                    on-tap="_handleTargetTap"
                     checked$="[[!isColumnHidden(item, displayedColumns)]]">
               </td>
             </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 7b74096..7d109633 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -35,40 +35,42 @@
       Gerrit.ChangeTableBehavior,
     ],
 
-    _getButtonText(isShown) {
-      return isShown ? 'Hide' : 'Show';
-    },
-
-    _updateDisplayedColumns(displayedColumns, name, checked) {
-      if (!checked) {
-        return displayedColumns.filter(column => {
-          return name.toLowerCase() !== column.toLowerCase();
-        });
-      } else {
-        return displayedColumns.concat([name]);
-      }
+    /**
+     * Get the list of enabled column names from whichever checkboxes are
+     * checked (excluding the number checkbox).
+     * @return {!Array<string>}
+     */
+    _getDisplayedColumns() {
+      return Polymer.dom(this.root)
+          .querySelectorAll('.checkboxContainer input:not([name=number])')
+          .filter(checkbox => checkbox.checked)
+          .map(checkbox => checkbox.name);
     },
 
     /**
-     * Handles tap on either the checkbox itself or the surrounding table cell.
+     * Handle a tap on a checkbox container and relay the tap to the checkbox it
+     * contains.
+     */
+    _handleCheckboxContainerTap(e) {
+      const checkbox = Polymer.dom(e.target).querySelector('input');
+      if (!checkbox) { return; }
+      checkbox.click();
+    },
+
+    /**
+     * Handle a tap on the number checkbox and update the showNumber property
+     * accordingly.
+     */
+    _handleNumberCheckboxTap(e) {
+      this.showNumber = Polymer.dom(e).rootTarget.checked;
+    },
+
+    /**
+     * Handle a tap on a displayed column checkboxes (excluding number) and
+     * update the displayedColumns property accordingly.
      */
     _handleTargetTap(e) {
-      let checkbox = Polymer.dom(e.target).querySelector('input');
-      if (checkbox) {
-        checkbox.click();
-      } else {
-        // The target is the checkbox itself.
-        checkbox = Polymer.dom(e).rootTarget;
-      }
-
-      if (checkbox.name === 'number') {
-        this.showNumber = checkbox.checked;
-        return;
-      }
-
-      this.set('displayedColumns',
-          this._updateDisplayedColumns(
-              this.displayedColumns, checkbox.name, checkbox.checked));
+      this.set('displayedColumns', this._getDisplayedColumns());
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index 587cc3b..32fab9d 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -53,6 +53,7 @@
       ];
 
       element.set('displayedColumns', columns);
+      element.showNumber = false;
       flushAsynchronousOperations();
     });
 
@@ -108,66 +109,50 @@
           displayedLength + 1);
     });
 
-    test('_handleTargetTap', () => {
-      const checkbox = element.$$('table tr:nth-child(2) input');
-      let originalDisplayedColumns = element.displayedColumns;
-      const td = element.$$('table tr:nth-child(2) .checkboxContainer');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
-
-      MockInteractions.tap(checkbox);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
-
-      originalDisplayedColumns = element.displayedColumns;
-      MockInteractions.tap(td);
-      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
-          originalDisplayedColumns,
-          checkbox.name,
-          checkbox.checked));
+    test('_getDisplayedColumns', () => {
+      assert.deepEqual(element._getDisplayedColumns(), columns);
+      MockInteractions.tap(
+          element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.deepEqual(element._getDisplayedColumns(),
+          columns.filter(c => c !== 'Assignee'));
     });
 
-    test('_handleTargetTap on number', () => {
-      element.showNumber = false;
-      const checkbox = element.$$('table tr:nth-child(1) input');
-      const displayedColumnStub =
-          sandbox.stub(element, '_updateDisplayedColumns');
+    test('_handleCheckboxContainerTap relayes taps to checkboxes', () => {
+      sandbox.stub(element, '_handleNumberCheckboxTap');
+      sandbox.stub(element, '_handleTargetTap');
 
-      MockInteractions.tap(checkbox);
-      assert.isFalse(displayedColumnStub.called);
+      MockInteractions.tap(
+          element.$$('table tr:first-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isFalse(element._handleTargetTap.called);
+
+      MockInteractions.tap(
+          element.$$('table tr:last-of-type .checkboxContainer'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
+      assert.isTrue(element._handleTargetTap.calledOnce);
+    });
+
+    test('_handleNumberCheckboxTap', () => {
+      sandbox.spy(element, '_handleNumberCheckboxTap');
+
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledOnce);
       assert.isTrue(element.showNumber);
 
-      MockInteractions.tap(checkbox);
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=number]'));
+      assert.isTrue(element._handleNumberCheckboxTap.calledTwice);
       assert.isFalse(element.showNumber);
     });
 
-    test('_updateDisplayedColumns', () => {
-      let name = 'Subject';
-      let checked = false;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Status',
-            'Owner',
-            'Assignee',
-            'Repo',
-            'Branch',
-            'Updated',
-          ]);
-      name = 'Size';
-      checked = true;
-      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
-          [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Repo',
-            'Branch',
-            'Updated',
-            'Size',
-          ]);
+    test('_handleTargetTap', () => {
+      sandbox.spy(element, '_handleTargetTap');
+      assert.include(element.displayedColumns, 'Assignee');
+      MockInteractions
+          .tap(element.$$('.checkboxContainer input[name=Assignee]'));
+      assert.isTrue(element._handleTargetTap.calledOnce);
+      assert.notInclude(element.displayedColumns, 'Assignee');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
index 52785ff..32ca557 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
@@ -20,7 +20,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-copy-clipboard">
   <template>
@@ -30,15 +29,11 @@
         display: flex;
         flex-wrap: wrap;
       }
-      .text label {
-        flex: 0 0 100%;
-      }
       .copyText {
         flex-grow: 1;
         margin-right: .3em;
       }
-      .hideInput,
-      .hideLabel label {
+      .hideInput {
         display: none;
       }
       input {
@@ -51,8 +46,7 @@
         width: 1.2em;
       }
     </style>
-    <div class$="text [[_computeLabelClass(hideLabel)]]">
-        <label>[[title]]</label>
+    <div class="text">
         <input id="input" is="iron-input"
             class$="copyText [[_computeInputClass(hideInput)]]"
             type="text"
@@ -67,8 +61,7 @@
             on-tap="_copyToClipboard">
           <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
         </gr-button>
-      </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    </div>
   </template>
   <script src="gr-copy-clipboard.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index cd8cb00..cabee36 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -24,7 +24,6 @@
 
     properties: {
       text: String,
-      title: String,
       buttonTitle: String,
       hasTooltip: {
         type: Boolean,
@@ -34,10 +33,6 @@
         type: Boolean,
         value: false,
       },
-      hideLabel: {
-        type: Boolean,
-        value: false,
-      },
     },
 
     focusOnCopy() {
@@ -48,10 +43,6 @@
       return hideInput ? 'hideInput' : '';
     },
 
-    _computeLabelClass(hideLabel) {
-      return hideLabel ? 'hideLabel' : '';
-    },
-
     _handleInputTap(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index c865917..d6e9dca 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -38,12 +38,8 @@
     let sandbox;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        saveChangeStarred() { return Promise.resolve({ok: true}); },
-      });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.title = 'Checkout';
       element.text = `git fetch http://gerrit@localhost:8080/a/test-project
           refs/changes/05/5/1 && git checkout FETCH_HEAD`;
       flushAsynchronousOperations();
@@ -79,12 +75,5 @@
       flushAsynchronousOperations();
       assert.equal(getComputedStyle(element.$.input).display, 'none');
     });
-
-    test('hideLabel', () => {
-      assert.notEqual(getComputedStyle(element.$$('label')).display, 'none');
-      element.hideLabel = true;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$$('label')).display, 'none');
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 83e99be..bfa7885 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -20,7 +20,7 @@
 
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -52,7 +52,7 @@
         display: flex;
         flex-direction: column;
       }
-      gr-copy-clipboard {
+      gr-shell-command {
         width: 60em;
         margin-bottom: .5em;
       }
@@ -75,9 +75,9 @@
       <template is="dom-repeat"
           items="[[commands]]"
           as="command">
-        <gr-copy-clipboard
-            title=[[command.title]]
-            text=[[command.command]]></gr-copy-clipboard>
+        <gr-shell-command
+            label=[[command.title]]
+            command=[[command.command]]></gr-shell-command>
       </template>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index 319cd04..ca77a30 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -44,7 +44,7 @@
     },
 
     focusOnCopy() {
-      this.$$('gr-copy-clipboard').focusOnCopy();
+      this.$$('gr-shell-command').focusOnCopy();
     },
 
     _getLoggedIn() {
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index 47219a7..c59e56a 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -74,7 +74,7 @@
       });
 
       test('focusOnCopy', () => {
-        const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+        const focusStub = sandbox.stub(element.$$('gr-shell-command'),
             'focusOnCopy');
         element.focusOnCopy();
         assert.isTrue(focusStub.called);
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 c1d0936..e0c7c37 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -78,6 +78,16 @@
       assert.strictEqual(plugin, otherPlugin);
     });
 
+    test('flushes preinstalls if provided', () => {
+      assert.doesNotThrow(() => {
+        Gerrit._flushPreinstalls();
+      });
+      window.Gerrit.flushPreinstalls = sandbox.stub();
+      Gerrit._flushPreinstalls();
+      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+      delete window.Gerrit.flushPreinstalls;
+    });
+
     test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
@@ -429,6 +439,16 @@
       assert.strictEqual(pluginApi.getPluginName(), 'foo');
     });
 
+    test('installing preloaded plugin', () => {
+      let plugin;
+      window.ASSETS_PATH = 'http://blips.com/chitz/';
+      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          'http://blips.com/plugins/foo/some/thing.html');
+      delete window.ASSETS_PATH;
+    });
+
     suite('test plugin with base url', () => {
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
index 57cbc85..c18f753 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -43,10 +43,14 @@
    * @param {string} method HTTP Method (GET, POST, etc)
    * @param {string} url URL without base path or plugin prefix
    * @param {Object=} payload Respected for POST and PUT only.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
    * @return {!Promise}
    */
-  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload) {
-    return getRestApi().send(method, this.opt_prefix + url, opt_payload);
+  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
+      opt_errFn) {
+    return getRestApi().send(method, this.opt_prefix + url, opt_payload,
+        opt_errFn);
   };
 
   /**
@@ -54,10 +58,13 @@
    * @param {string} method HTTP Method (GET, POST, etc)
    * @param {string} url URL without base path or plugin prefix
    * @param {Object=} payload Respected for POST and PUT only.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
    * @return {!Promise} resolves on success, rejects on error.
    */
-  GrPluginRestApi.prototype.send = function(method, url, opt_payload) {
-    return this.fetch(method, url, opt_payload).then(response => {
+  GrPluginRestApi.prototype.send = function(method, url, opt_payload,
+      opt_errFn) {
+    return this.fetch(method, url, opt_payload, opt_errFn).then(response => {
       if (response.status < 200 || response.status >= 300) {
         return response.text().then(text => {
           if (text) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 1efc176..36a428d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -103,6 +103,12 @@
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function flushPreinstalls() {
+    if (window.Gerrit.flushPreinstalls) {
+      window.Gerrit.flushPreinstalls();
+    }
+  }
+
   function installPreloadedPlugins() {
     if (!Gerrit._preloadedPlugins) { return; }
     for (const name in Gerrit._preloadedPlugins) {
@@ -161,6 +167,13 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
+    if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Original plugin URL is used in plugin assets URLs calculation.
+      const assetsBaseUrl = window.ASSETS_PATH ||
+          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
+      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
+          '/static/' + this._name + '.js');
+    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -435,6 +448,8 @@
     },
   };
 
+  flushPreinstalls();
+
   const Gerrit = window.Gerrit || {};
 
   let _resolveAllPluginsLoaded = null;
@@ -447,6 +462,7 @@
   if (!app) {
     // No gr-app found (running tests)
     Gerrit._installPreloadedPlugins = installPreloadedPlugins;
+    Gerrit._flushPreinstalls = flushPreinstalls;
     Gerrit._resetPlugins = () => {
       _allPluginsPromise = null;
       _pluginsInstalled = [];
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
index 2aead04..ca5c49f 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -29,12 +29,6 @@
   <template strip-whitespace>
     <style include="gr-voting-styles"></style>
     <style include="shared-styles">
-      .title {
-        font-weight: bold;
-        max-width: 20em;
-        padding-right: .5em;
-        word-break: break-word;
-      }
       .placeholder {
         color: var(--deemphasized-text-color);
         padding-top: .2em;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
index ec589fe..c35768f 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -16,6 +16,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../bower_components/ba-linkify/ba-linkify.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 22e14e9..530da02 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -60,15 +60,16 @@
      *     commentLink patterns
      */
     _contentOrConfigChanged(content, config) {
-      var output = Polymer.dom(this.$.output);
+      config = Gerrit.Nav.mapCommentlinks(config);
+      const output = Polymer.dom(this.$.output);
       output.textContent = '';
-      var parser = new GrLinkTextParser(config,
+      const parser = new GrLinkTextParser(config,
           this._handleParseResult.bind(this), this.removeZeroWidthSpace);
       parser.parse(content);
 
       // Ensure that links originating from HTML commentlink configs open in a
       // new tab. @see Issue 5567
-      output.querySelectorAll('a').forEach(function(anchor) {
+      output.querySelectorAll('a').forEach(anchor => {
         anchor.setAttribute('target', '_blank');
         anchor.setAttribute('rel', 'noopener');
       });
@@ -87,9 +88,9 @@
      * @param  {DocumentFragment|undefined} fragment
      */
     _handleParseResult(text, href, fragment) {
-      var output = Polymer.dom(this.$.output);
+      const output = Polymer.dom(this.$.output);
       if (href) {
-        var a = document.createElement('a');
+        const a = document.createElement('a');
         a.href = href;
         a.textContent = text;
         a.target = '_blank';
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index baa025e..23c1442 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -37,29 +37,30 @@
 </test-fixture>
 
 <script>
-  suite('gr-linked-text tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
       element.config = {
         ph: {
           match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2'
+          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2',
         },
         changeid: {
           match: '(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         changeid2: {
           match: 'Change-Id: +(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         googlesearch: {
           match: 'google:(.+)',
-          link: 'https://bing.com/search?q=$1',  // html should supercede link.
+          link: 'https://bing.com/search?q=$1', // html should supercede link.
           html: '<a href="https://google.com/search?q=$1">$1</a>',
         },
         hashedhtml: {
@@ -74,27 +75,27 @@
       };
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('URL pattern was parsed and linked.', function() {
-      // Reguar inline link.
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+    test('URL pattern was parsed and linked.', () => {
+      // Regular inline link.
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       element.content = url;
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.rel, 'noopener');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, url);
     });
 
-    test('Bug pattern was parsed and linked', function() {
+    test('Bug pattern was parsed and linked', () => {
       // "Issue/Bug" pattern.
       element.content = 'Issue 3650';
 
-      var linkEl = element.$.output.childNodes[0];
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      let linkEl = element.$.output.childNodes[0];
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, 'Issue 3650');
@@ -107,26 +108,26 @@
       assert.equal(linkEl.textContent, 'Bug 3650');
     });
 
-    test('Change-Id pattern was parsed and linked', function() {
+    test('Change-Id pattern was parsed and linked', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
       element.content = prefix + changeID;
 
-      var textNode = element.$.output.childNodes[0];
-      var linkEl = element.$.output.childNodes[1];
+      const textNode = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[1];
       assert.equal(textNode.textContent, prefix);
-      var url = '/q/' + changeID;
+      const url = '/q/' + changeID;
       assert.equal(linkEl.target, '_blank');
       // Since url is a path, the host is added automatically.
       assert.isTrue(linkEl.href.endsWith(url));
       assert.equal(linkEl.textContent, changeID);
     });
 
-    test('Multiple matches', function() {
+    test('Multiple matches', () => {
       element.content = 'Issue 3650\nIssue 3450';
-      var linkEl1 = element.$.output.childNodes[0];
-      var linkEl2 = element.$.output.childNodes[2];
+      const linkEl1 = element.$.output.childNodes[0];
+      const linkEl2 = element.$.output.childNodes[2];
 
       assert.equal(linkEl1.target, '_blank');
       assert.equal(linkEl1.href,
@@ -139,22 +140,22 @@
       assert.equal(linkEl2.textContent, 'Issue 3450');
     });
 
-    test('Change-Id pattern parsed before bug pattern', function() {
+    test('Change-Id pattern parsed before bug pattern', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
 
       // "Issue/Bug" pattern.
-      var bug = 'Issue 3650';
+      const bug = 'Issue 3650';
 
-      var changeUrl = '/q/' + changeID;
-      var bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      const changeUrl = '/q/' + changeID;
+      const bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
 
       element.content = prefix + changeID + bug;
 
-      var textNode = element.$.output.childNodes[0];
-      var changeLinkEl = element.$.output.childNodes[1];
-      var bugLinkEl = element.$.output.childNodes[2];
+      const textNode = element.$.output.childNodes[0];
+      const changeLinkEl = element.$.output.childNodes[1];
+      const bugLinkEl = element.$.output.childNodes[2];
 
       assert.equal(textNode.textContent, prefix);
 
@@ -167,41 +168,41 @@
       assert.equal(bugLinkEl.textContent, 'Issue 3650');
     });
 
-    test('html field in link config', function() {
+    test('html field in link config', () => {
       element.content = 'google:do a barrel roll';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.getAttribute('href'),
           'https://google.com/search?q=do a barrel roll');
       assert.equal(linkEl.textContent, 'do a barrel roll');
     });
 
-    test('removing hash from links', function() {
+    test('removing hash from links', () => {
       element.content = 'hash:foo';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
       assert.equal(linkEl.textContent, 'foo');
     });
 
-    test('disabled config', function() {
+    test('disabled config', () => {
       element.content = 'foo:baz';
       assert.equal(element.$.output.innerHTML, 'foo:baz');
     });
 
-    test('R=email labels link correctly', function() {
+    test('R=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'R=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'R=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
     });
 
-    test('CC=email labels link correctly', function() {
+    test('CC=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'CC=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'CC=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
     });
 
-    test('only {http,https,mailto} protocols are linkified', function() {
+    test('only {http,https,mailto} protocols are linkified', () => {
       element.content = 'xx mailto:test@google.com yy';
       let links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
@@ -226,7 +227,7 @@
       assert.equal(links.length, 0);
     });
 
-    test('overlapping links', function() {
+    test('overlapping links', () => {
       element.config = {
         b1: {
           match: '(B:\\s*)(\\d+)',
@@ -238,7 +239,7 @@
         },
       };
       element.content = '- B: 123, 45';
-      var links = Polymer.dom(element.root).querySelectorAll('a');
+      const links = Polymer.dom(element.root).querySelectorAll('a');
 
       assert.equal(links.length, 2);
       assert.equal(element.$$('span').textContent, '- B: 123, 45');
@@ -250,31 +251,31 @@
       assert.equal(links[1].textContent, '45');
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isTrue(contentConfigStub.called);
     });
   });
 
-  suite('gr-linked-text with null config', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text with null config', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged not called without config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isFalse(contentConfigStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 8b49ca0..8526c3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -41,8 +41,8 @@
    * @param {Object|null|undefined} linkConfig Comment links as specified by the
    *     commentlinks field on a project config.
    * @param {Function} callback The callback to be fired when an intermediate
-   *     parse result is emitted. The callback is passed text and href strings if
-   *     a link is to be created, or a document fragment otherwise.
+   *     parse result is emitted. The callback is passed text and href strings
+   *     if a link is to be created, or a document fragment otherwise.
    * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
    *     spaces will be removed from R=<email> and CC=<email> expressions.
    */
@@ -73,14 +73,14 @@
    */
   GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
     this.sortArrayReverse(outputArray);
-    var fragment = document.createDocumentFragment();
-    var cursor = text.length;
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
 
     // Start inserting linkified URLs from the end of the String. That way, the
     // string positions of the items don't change as we iterate through.
-    outputArray.forEach(function(item) {
-      // Add any text between the current linkified item and the item added before
-      // if it exists.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
       if (item.position + item.length !== cursor) {
         fragment.insertBefore(
             document.createTextNode(
@@ -130,32 +130,32 @@
    */
   GrLinkTextParser.prototype.addItem =
       function(text, href, html, position, length, outputArray) {
-    var htmlOutput = '';
+        let htmlOutput = '';
 
-    if (href) {
-      var a = document.createElement('a');
-      a.href = href;
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      htmlOutput = a;
-    } else if (html) {
-      var fragment = document.createDocumentFragment();
+        if (href) {
+          const a = document.createElement('a');
+          a.href = href;
+          a.textContent = text;
+          a.target = '_blank';
+          a.rel = 'noopener';
+          htmlOutput = a;
+        } else if (html) {
+          const fragment = document.createDocumentFragment();
       // Create temporary div to hold the nodes in.
-      var div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      htmlOutput = fragment;
-    }
+          const div = document.createElement('div');
+          div.innerHTML = html;
+          while (div.firstChild) {
+            fragment.appendChild(div.firstChild);
+          }
+          htmlOutput = fragment;
+        }
 
-    outputArray.push({
-      html: htmlOutput,
-      position: position,
-      length: length,
-    });
-  };
+        outputArray.push({
+          html: htmlOutput,
+          position,
+          length,
+        });
+      };
 
   /**
    * Create a CommentLinkItem for a link and append it to the given output
@@ -171,9 +171,9 @@
    */
   GrLinkTextParser.prototype.addLink =
       function(text, href, position, length, outputArray) {
-    if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(text, href, null, position, length, outputArray);
-  };
+        if (!text || this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(text, href, null, position, length, outputArray);
+      };
 
   /**
    * Create a CommentLinkItem specified by an HTMl string and append it to the
@@ -188,9 +188,9 @@
    */
   GrLinkTextParser.prototype.addHTML =
       function(html, position, length, outputArray) {
-    if (this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(null, null, html, position, length, outputArray);
-  };
+        if (this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(null, null, html, position, length, outputArray);
+      };
 
   /**
    * Does the given range overlap with anything already in the item list.
@@ -200,18 +200,18 @@
    */
   GrLinkTextParser.prototype.hasOverlap =
       function(position, length, outputArray) {
-    var endPosition = position + length;
-    for (var i = 0; i < outputArray.length; i++) {
-      var arrayItemStart = outputArray[i].position;
-      var arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if ((position >= arrayItemStart && position < arrayItemEnd) ||
+        const endPosition = position + length;
+        for (let i = 0; i < outputArray.length; i++) {
+          const arrayItemStart = outputArray[i].position;
+          const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+          if ((position >= arrayItemStart && position < arrayItemEnd) ||
         (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
         (position === arrayItemStart && position === arrayItemEnd)) {
             return true;
-      }
-    }
-    return false;
-  };
+          }
+        }
+        return false;
+      };
 
   /**
    * Parse the given source text and emit callbacks for the items that are
@@ -241,9 +241,9 @@
       text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
     }
 
-    // If the href is provided then ba-linkify has recognized it as a URL. If the
-    // source text does not include a protocol, the protocol will be added by
-    // ba-linkify. Create the link if the href is provided and its protocol
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
     if (href && URL_PROTOCOL_PATTERN.test(href)) {
       this.addText(text, href);
@@ -262,9 +262,10 @@
    *   object.
    */
   GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-    // The outputArray is used to store all of the matches found for all patterns.
-    var outputArray = [];
-    for (var p in patterns) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray = [];
+    for (const p in patterns) {
       if (patterns[p].enabled != null && patterns[p].enabled == false) {
         continue;
       }
@@ -279,38 +280,37 @@
         }
       }
 
-      var pattern = new RegExp(patterns[p].match, 'g');
+      const pattern = new RegExp(patterns[p].match, 'g');
 
-      var match;
-      var textToCheck = text;
-      var susbtrIndex = 0;
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
 
       while ((match = pattern.exec(textToCheck)) != null) {
         textToCheck = textToCheck.substr(match.index + match[0].length);
-        var result = match[0].replace(pattern,
+        let result = match[0].replace(pattern,
             patterns[p].html || patterns[p].link);
 
+        let i;
         // Skip portion of replacement string that is equal to original.
-        for (var i = 0; i < result.length; i++) {
-          if (result[i] !== match[0][i]) {
-            break;
-          }
+        for (i = 0; i < result.length; i++) {
+          if (result[i] !== match[0][i]) { break; }
         }
         result = result.slice(i);
 
         if (patterns[p].html) {
           this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else if (patterns[p].link) {
           this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              match[0],
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else {
           throw Error('linkconfig entry ' + p +
               ' doesn’t contain a link or html attribute.');
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index d014758..af10b8371 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
@@ -61,7 +61,6 @@
    *   endpoint: string,
    *   patchNum: (string|number|null|undefined),
    *   errFn: (function(?Response, string=)|null|undefined),
-   *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
    *   anonymizedEndpoint: (string|undefined),
@@ -1498,13 +1497,37 @@
      * @return {!Promise<?Object>}
      */
     getRepos(filter, reposPerPage, opt_offset) {
+      const defaultFilter = 'state:active OR state:read-only';
+      const namePartDelimiters = /[@.\-\s\/_]/g;
       const offset = opt_offset || 0;
 
+      if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+        // The query language specifies hyphens as operators. Split the string
+        // by hyphens and 'AND' the parts together as 'inname:' queries.
+        // If the filter includes a semicolon, the user is using a more complex
+        // query so we trust them and don't do any magic under the hood.
+        const originalFilter = filter;
+        filter = '';
+        originalFilter.split(namePartDelimiters).forEach(part => {
+          if (part) {
+            filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+          }
+        });
+      }
+      // Check if filter is now empty which could be either because the user did
+      // not provide it or because the user provided only a split character.
+      if (!filter) {
+        filter = defaultFilter;
+      }
+
+      filter = filter.trim();
+      const encodedFilter = encodeURIComponent(filter);
+
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
       return this._fetchSharedCacheURL({
-        url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
-            this._computeFilter(filter),
+        url: `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+            `&query=${encodedFilter}`,
         anonymizedUrl: '/projects/?*',
       });
     },
@@ -2112,10 +2135,8 @@
      * @param {number|string} patchNum
      * @param {string} path
      * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
      */
-    getDiff(changeNum, basePatchNum, patchNum, path,
-        opt_errFn, opt_cancelCondition) {
+    getDiff(changeNum, basePatchNum, patchNum, path, opt_errFn) {
       const params = {
         context: 'ALL',
         intraline: null,
@@ -2133,7 +2154,6 @@
         endpoint,
         patchNum,
         errFn: opt_errFn,
-        cancelCondition: opt_cancelCondition,
         params,
         anonymizedEndpoint: '/files/*/diff',
       });
@@ -2796,7 +2816,6 @@
         return this._fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
-          cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
           anonymizedUrl: anonymizedEndpoint ?
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 193d306..d9656e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -930,33 +930,68 @@
       });
     });
 
-    test('getRepos', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('test', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&m=test');
+    suite('getRepos', () => {
+      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
 
-      element.getRepos(null, 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0');
+      setup(() => {
+        sandbox.stub(element, '_fetchSharedCacheURL');
+      });
 
-      element.getRepos('test', 25, 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=25&m=test');
-    });
+      test('normal use', () => {
+        element.getRepos('test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=test');
 
-    test('getRepos filter', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('test/test/test', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest');
-    });
+        element.getRepos(null, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
 
-    test('getRepos filter regex', () => {
-      sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getRepos('^test.*', 25);
-      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-          '/projects/?d&n=26&S=0&r=%5Etest.*');
+        element.getRepos('test', 25, 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=25&query=test');
+      });
+
+      test('with blank', () => {
+        element.getRepos('test/test', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
+      });
+
+      test('with hyphen', () => {
+        element.getRepos('foo-bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with leading hyphen', () => {
+        element.getRepos('-bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Abar');
+      });
+
+      test('with trailing hyphen', () => {
+        element.getRepos('foo-bar-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('hyphen only', () => {
+        element.getRepos('-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
+      });
     });
 
     test('getGroups filter regex', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
new file mode 100644
index 0000000..fe6ed88
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
@@ -0,0 +1,58 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+
+<dom-module id="gr-shell-command">
+  <template>
+    <style include="shared-styles">
+      .commandContainer {
+        margin-bottom: .75em;
+      }
+      .commandContainer {
+        background-color: var(--shell-command-background-color);
+        padding: .5em .5em .5em 2.5em;
+        position: relative;
+        width: 100%;
+      }
+      .commandContainer:before {
+        background: var(--shell-command-decoration-background-color);
+        bottom: 0;
+        box-sizing: border-box;
+        content: '$';
+        display: block;
+        left: 0;
+        padding: .8em;
+        position: absolute;
+        top: 0;
+        width: 2em;
+      }
+      .commandContainer gr-copy-clipboard {
+        --text-container-style: {
+          border: none;
+        }
+      }
+    </style>
+    <label>[[label]]</label>
+    <div class="commandContainer">
+      <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
+    </div>
+  </template>
+  <script src="gr-shell-command.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
new file mode 100644
index 0000000..2c546cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-shell-command',
+
+    properties: {
+      command: String,
+      label: String,
+    },
+
+    focusOnCopy() {
+      this.$$('gr-copy-clipboard').focusOnCopy();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
new file mode 100644
index 0000000..a49f76f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-shell-command</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-shell-command.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-shell-command></gr-shell-command>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-shell-command tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('focusOnCopy', () => {
+      const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+          'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
index a88f68c..b82bf3a 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -39,7 +39,7 @@
       }
       .info > div > span {
         display: inline-block;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         text-align: right;
         width: 4em;
       }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
index 81c195a..ea81796 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ b/polygerrit-ui/app/styles/themes/app-theme.html
@@ -96,6 +96,9 @@
   --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
   --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
 
+  --shell-command-background-color: #f5f5f5;
+  --shell-command-decoration-background-color: #ebebeb;
+
   --comment-text-color: #000;
   --comment-background-color: #fcfad6;
   --unresolved-comment-background-color: #fcfaa6;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 1f473da..8ade9ba 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -29,6 +29,7 @@
       --diff-selection-background-color: #3A71D8;
       --light-remove-highlight-color: rgb(53, 27, 27);
       --light-add-highlight-color: rgb(24, 45, 24);
+      --light-remove-add-highlight-color: #2f3f2f;
       --light-rebased-remove-highlight-color: rgb(60, 37, 8);
       --light-rebased-add-highlight-color: rgb(72, 113, 101);
       --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
@@ -39,6 +40,8 @@
       --diff-context-control-border-color: var(--border-color);
       --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
       --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --shell-command-background-color: #5f5f5f;
+      --shell-command-decoration-background-color: #999999;
       --comment-text-color: var(--primary-text-color);
       --comment-background-color: #0B162B;
       --unresolved-comment-background-color: rgb(56, 90, 154);
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index d0bb4c1..d463d61 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -86,7 +86,9 @@
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
     'change/gr-thread-list/gr-thread-list_test.html',
+    'change/gr-upload-help-dialog/gr-upload-help-dialog_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
+    'core/gr-error-dialog/gr-error-dialog_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
diff --git a/proto/cache.proto b/proto/cache.proto
index 33f9143..c2ac0d9 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -219,3 +219,18 @@
   }
   TagSetProto tags = 2;
 }
+
+// Serialized form of
+// com.google.gerrit.server.account.externalids.AllExternalIds.
+// Next ID: 2
+message AllExternalIdsProto {
+  // Next ID: 6
+  message ExternalIdProto {
+    string key = 1;
+    int32 accountId = 2;
+    string email = 3;
+    string password = 4;
+    bytes blobId = 5;
+  }
+  repeated ExternalIdProto external_id = 1;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 052de6b..f401735 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,7 +19,8 @@
 /**
  * @param canonicalPath
  * @param staticResourcePath
- * @param? assetsUrl
+ * @param? assetsPath {string} URL to static assets root, if served from CDN.
+ * @param? assetsBundle {string} Assets bundle .html file, served from $assetsPath.
  * @param? faviconPath
  * @param? versionInfo
  * @param? deprecateGwtUi
@@ -37,6 +38,7 @@
     {if $deprecateGwtUi}window.DEPRECATE_GWT_UI = true;{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
+    {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -62,8 +64,8 @@
   // CC them on any changes that load content before gr-app.html.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  {if $assetsUrl}
-    <link rel="import" href="{$assetsUrl}">{\n}
+  {if $assetsPath and $assetsBundle}
+    <link rel="import" href="{$assetsPath + $assetsBundle}">{\n}
   {/if}
 
   <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index 5571e7c..d3f3666 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -443,6 +443,11 @@
                 echo -16 > "/proc/${PID}/oom_adj"
             fi
         fi
+    elif [ "$(uname -s)"=="Linux" ] && test -d "/proc/${PID}"; then
+        echo "WARNING: Could not adjust Gerrit's process for the kernel's out-of-memory killer."
+        echo "         This may be caused by ${0} not being run as root."
+        echo "         Consider changing the OOM score adjustment manually for Gerrit's PID=${PID} with e.g.:"
+        echo "         echo '-1000' | sudo tee /proc/${PID}/oom_score_adj"
     fi
 
     TIMEOUT="$GERRIT_STARTUP_TIMEOUT"
diff --git a/resources/com/google/gerrit/server/documentation/pegdown.css b/resources/com/google/gerrit/server/documentation/flexmark-java.css
similarity index 100%
rename from resources/com/google/gerrit/server/documentation/pegdown.css
rename to resources/com/google/gerrit/server/documentation/flexmark-java.css
diff --git a/tools/BUILD b/tools/BUILD
index 060cbd8..73ecfb9 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,6 +1,128 @@
+load("@bazel_tools//tools/jdk:default_java_toolchain.bzl", "default_java_toolchain")
+
 py_binary(
     name = "merge_jars",
     srcs = ["merge_jars.py"],
     main = "merge_jars.py",
     visibility = ["//visibility:public"],
 )
+
+# TODO(davido): remove this when minimum suported Bazel version >= 0.17
+# Copied from tools/jdk/default_java_toolchain.bzl to make Bazel 0.16
+# and later Bazel released to work as expected. See this issue for context:
+# https://github.com/bazelbuild/bazel/issues/6009
+JDK9_JVM_OPTS = [
+    # Allow JavaBuilder to access internal javac APIs.
+    "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
+    "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
+    "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+
+    # override the javac in the JDK.
+    "--patch-module=java.compiler=$(location @bazel_tools//third_party/java/jdk/langtools:java_compiler_jar)",
+    "--patch-module=jdk.compiler=$(location @bazel_tools//third_party/java/jdk/langtools:jdk_compiler_jar)",
+
+    # quiet warnings from com.google.protobuf.UnsafeUtil,
+    # see: https://github.com/google/protobuf/issues/3781
+    "--add-opens=java.base/java.nio=ALL-UNNAMED",
+]
+
+# See https://github.com/bazelbuild/bazel/issues/3427 for more context
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain_bazel_0.16",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    jvm_opts = JDK9_JVM_OPTS,
+    package_configuration = [
+        ":error_prone",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    package_configuration = [
+        ":error_prone",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+# This EP warnings list is based on:
+# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+java_package_configuration(
+    name = "error_prone",
+    javacopts = [
+        "-XepDisableWarningsInGeneratedCode",
+        "-Xep:MissingCasesInEnumSwitch:ERROR",
+        "-Xep:ReferenceEquality:WARN",
+        "-Xep:StringEquality:WARN",
+        "-Xep:WildcardImport:WARN",
+        "-Xep:AmbiguousMethodReference:WARN",
+        "-Xep:BadAnnotationImplementation:WARN",
+        "-Xep:BadComparable:WARN",
+        "-Xep:BoxedPrimitiveConstructor:ERROR",
+        "-Xep:CannotMockFinalClass:WARN",
+        "-Xep:ClassCanBeStatic:WARN",
+        "-Xep:ClassNewInstance:WARN",
+        "-Xep:DefaultCharset:ERROR",
+        "-Xep:DoubleCheckedLocking:WARN",
+        "-Xep:ElementsCountedInLoop:WARN",
+        "-Xep:EqualsHashCode:WARN",
+        "-Xep:EqualsIncompatibleType:WARN",
+        "-Xep:ExpectedExceptionChecker:ERROR",
+        "-Xep:Finally:WARN",
+        "-Xep:FloatingPointLiteralPrecision:WARN",
+        "-Xep:FragmentInjection:WARN",
+        "-Xep:FragmentNotInstantiable:WARN",
+        "-Xep:FunctionalInterfaceClash:WARN",
+        "-Xep:FutureReturnValueIgnored:WARN",
+        "-Xep:GetClassOnEnum:WARN",
+        "-Xep:ImmutableAnnotationChecker:WARN",
+        "-Xep:ImmutableEnumChecker:WARN",
+        "-Xep:IncompatibleModifiers:WARN",
+        "-Xep:InjectOnConstructorOfAbstractClass:WARN",
+        "-Xep:InputStreamSlowMultibyteRead:WARN",
+        "-Xep:IterableAndIterator:WARN",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:WARN",
+        "-Xep:JUnitAmbiguousTestClass:WARN",
+        "-Xep:LiteralClassName:WARN",
+        "-Xep:MissingFail:WARN",
+        "-Xep:MissingOverride:WARN",
+        "-Xep:MutableConstantField:WARN",
+        "-Xep:NarrowingCompoundAssignment:WARN",
+        "-Xep:NonAtomicVolatileUpdate:WARN",
+        "-Xep:NonOverridingEquals:WARN",
+        "-Xep:NullableConstructor:WARN",
+        "-Xep:NullablePrimitive:WARN",
+        "-Xep:NullableVoid:WARN",
+        "-Xep:OperatorPrecedence:WARN",
+        "-Xep:OverridesGuiceInjectableMethod:WARN",
+        "-Xep:PreconditionsInvalidPlaceholder:WARN",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:WARN",
+        "-Xep:ProtocolBufferOrdinal:WARN",
+        "-Xep:RequiredModifiers:WARN",
+        "-Xep:ShortCircuitBoolean:WARN",
+        "-Xep:SimpleDateFormatConstant:WARN",
+        "-Xep:StaticGuardedByInstance:WARN",
+        "-Xep:SynchronizeOnNonFinalField:WARN",
+        "-Xep:TruthConstantAsserts:WARN",
+        "-Xep:TypeParameterShadowing:WARN",
+        "-Xep:TypeParameterUnusedInFormals:WARN",
+        "-Xep:URLEqualsHashCode:WARN",
+        "-Xep:UnsynchronizedOverridesSynchronized:WARN",
+        "-Xep:WaitNotInLoop:WARN",
+    ],
+    packages = ["error_prone_packages"],
+)
+
+package_group(
+    name = "error_prone_packages",
+    packages = [
+        "//java/...",
+        "//javatests/...",
+    ],
+)
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index 2adb7dd..b185214 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -15,7 +15,7 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:java.bzl", "java_library2")
 
-jar_filetype = FileType([".jar"])
+jar_filetype = [".jar"]
 
 BROWSERS = [
     "chrome",
@@ -225,7 +225,7 @@
             default = Label("@bazel_tools//tools/zip:zipper"),
             cfg = "host",
             executable = True,
-            single_file = True,
+            allow_single_file = True,
         ),
     },
     outputs = {
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 34ae9d2..8f2316c 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -36,7 +36,7 @@
         "rm -rf %s" % dir,
         "mkdir %s" % dir,
         " ".join([
-            ctx.file._javadoc.path,
+            "%s/bin/javadoc" % ctx.attr._jdk[java_common.JavaRuntimeInfo].java_home,
             "-Xdoclint:-missing",
             "-protected",
             "-encoding UTF-8",
@@ -67,14 +67,10 @@
         "pkgs": attr.string_list(),
         "title": attr.string(),
         "external_docs": attr.string_list(),
-        "_javadoc": attr.label(
-            default = Label("@local_jdk//:bin/javadoc"),
-            single_file = True,
-            allow_files = True,
-        ),
         "_jdk": attr.label(
-            default = Label("@local_jdk//:jdk-default"),
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
             allow_files = True,
+            providers = [java_common.JavaRuntimeInfo],
         ),
     },
     outputs = {"zip": "%{name}.zip"},
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index d6d0c95..0997bcb 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -426,7 +426,7 @@
 
 def bundle_assets(*args, **kwargs):
     """Combine html, js, css files and optionally split into js and html bundles."""
-    _bundle_rule(*args, pkg = PACKAGE_NAME, **kwargs)
+    _bundle_rule(*args, pkg = native.package_name(), **kwargs)
 
 def polygerrit_plugin(name, app, srcs = [], assets = None, **kwargs):
     """Bundles plugin dependencies for deployment.
@@ -447,7 +447,7 @@
         name = name + "_combined",
         app = app,
         srcs = srcs if app in srcs else srcs + [app],
-        pkg = PACKAGE_NAME,
+        pkg = native.package_name(),
         **kwargs
     )
 
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 476ccb9..ebe57f2 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -35,7 +35,7 @@
             continue
 
         handled_rules.append(rule_name)
-        for c in child.getchildren():
+        for c in list(child):
             if c.tag != "rule-input":
                 continue
 
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index f011446..d059216 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -39,7 +39,7 @@
     if target[0] not in ":/":
         target = ":" + target
     if target[0] != "/":
-        target = "//" + PACKAGE_NAME + target
+        target = "//" + native.package_name() + target
 
     forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
     native.genquery(
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 1a376e9..40dd769 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,7 @@
 
 # War packaging.
 
-jar_filetype = FileType([".jar"])
+jar_filetype = [".jar"]
 
 LIBS = [
     "//java/com/google/gerrit/common:version",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index d022c40..0c9d023 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -11,7 +11,6 @@
     "//gerrit-gwtui:ui_tests",
     "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     "//javatests/com/google/gerrit/server:server_tests",
-    "//proto:reviewdb_java_proto",
 ]
 
 DEPS = [
@@ -33,8 +32,7 @@
     "//lib/gwt:w3c-css-sac",
     "//lib/jetty:servlets",
     "//lib/prolog:compiler-lib",
-    # TODO(davido): I do not understand why it must be on the Eclipse classpath
-    #'//Documentation:index',
+    "//proto:reviewdb_java_proto",
 ]
 
 java_library(
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index b99c04e..64d837a 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -52,10 +52,12 @@
                 action='store', default='gerrit', dest='project_name')
 opts.add_option('-b', '--batch', action='store_true',
                 dest='batch', help='Bazel batch option')
+opts.add_option('-j', '--java', action='store',
+                dest='java', help='Post Java 8 support (9|10|11|...)')
 args, _ = opts.parse_args()
 
 batch_option = '--batch' if args.batch else None
-
+custom_java = args.java
 
 def _build_bazel_cmd(*args):
     cmd = ['bazel']
@@ -63,6 +65,9 @@
         cmd.append('--batch')
     for arg in args:
         cmd.append(arg)
+    if custom_java:
+        cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
+        cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
     return cmd
 
 
@@ -70,9 +75,10 @@
     return check_output(_build_bazel_cmd('info', 'output_base')).strip()
 
 
-def gen_bazel_path():
+def gen_bazel_path(ext_location):
     bazel = check_output(['which', 'bazel']).strip().decode('UTF-8')
     with open(path.join(ROOT, ".bazel_path"), 'w') as fd:
+        fd.write("output_base=%s\n" % ext_location)
         fd.write("bazel=%s\n" % bazel)
         fd.write("PATH=%s\n" % environ["PATH"])
 
@@ -301,7 +307,7 @@
     gen_project(args.project_name)
     gen_classpath(ext_location)
     gen_factorypath(ext_location)
-    gen_bazel_path()
+    gen_bazel_path(ext_location)
 
     # TODO(davido): Remove this when GWT gone
     gwt_working_dir = ".gwt_work_dir"